Skip to content

Commit edcddc7

Browse files
authored
feat(chat): project the steward channel's session mode and status (#4483)
Admin-bypass merge with the temporary gate bypass the owner authorized for this lane. Merged head: `610b9d86baf6b3b5693ce7978e9a1a344a542194` Why the bypass is needed here: the account is the PR author, so GitHub refuses a formal self-approval and the required formal review can never be satisfied on this branch. The equivalence check was published instead as a `COMMENTED` review carrying the full bilingual review body, and the capability validated its shape and verdict (`pull_request_review_conclusion_v0`: `valid=true`, `verdict=APPROVE`, no invalid reasons). Evidence at the merged head: - `loopx canary premerge --from-git-diff --goal-id loopx-meta` → `passed`, 11 selected checks, 0 failures, 0 manual holds, `merge_gate_passed=true`, `self_merge_allowed=true` - change-quality receipt `cqr_cc7041812e4ceb7c5644` → `state=valid` for scope fingerprint `cc7041812e4ceb7c5644227c131915c52a99401192c400d8d9873feddcfcb2e7` (8 changed files) - `pytest` focused steward suite → 122 passed - `examples/loopx-steward-channel-binding-smoke.py` → passed, including the new `mode_readback` probe - `ruff check` clean; `examples/docs-governance-smoke.py` ok; `examples/semantic-vocabulary-drift-smoke.py` ok - falsification: making the projector derive `managed_runtime` for a channel with no Session fails `test_a_channel_without_a_session_reads_as_unbound` Delivery boundary recorded in the PR: the dashboard chip does not render the new fields yet, because that changes the app's first viewport and needs an owner preview. Honest gap: the repository CI workflow was still running at merge time (12 jobs still pending after a bounded wait, 0 failing). The owner's standing instruction for this lane is not to idle on CI, so the remaining GitHub checks were not awaited; they can still be read on the merged commit.
1 parent 412be51 commit edcddc7

8 files changed

Lines changed: 300 additions & 6 deletions

‎docs/architecture/rfcs/harness-selection-dsh-pi-v0.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ operator-local path is recorded here.
401401
| Manager M2 — semantic continuation | Receiver resolution across registered running lanes; typed per-source coverage and freshness; a goal-level milestone the report can lead with instead of coverage disclaimers | Not implemented. Delegation resolves against the supplied delegation catalog, so a request whose owning lane is absent from that catalog is refused or routed to an unrelated lane; a provider read failure surfaces as raw error text instead of a typed source row; the manager context exposes deliveries and coverage but no goal-level milestone field to synthesize from |
402402
| Manager M3 — automatic complete exchange | A persisted answer that exceeds or violates the channel's outbound text contract is split and re-sent under a stable answer identity; an ambiguous or failed send is reconciled instead of replaced by a local notice; the return path survives a transport restart; rich markdown renders as structured text | Partially mitigated. `loopx/extensions/lark/outbound.py` fails closed on an over-limit or malformed payload, and the channel reports that local failure without re-delivering the persisted answer; one answer carries no idempotency identity, so a retry can duplicate it; structured rendering is not guaranteed |
403403
| Host modes M0-M1 | The channel's executor selection and its bounded one-segment execution | Selection is covered by PR #4446 and the Turn-side selection by PR #4443; bounded one-segment execution is covered by the Mode B acceptance above. The channel itself now reaches the managed host through the segment transport, so the managed host's own one-segment execution is reachable from the channel; what remains open is that the segment is not a session, so cross-turn host continuity is still not offered |
404-
| Host modes M2-M3 | Attached-host parity, typed unavailability, and mode-aware projection with no mode inference and no second executor | Partly shipped: the channel's managed segment transport holds one executor per binding, refuses a second start with the typed `managed_host_chat_segment_in_flight`, and discards an interrupted segment's answer instead of letting it enter visible history. Still not implemented: attached-host parity, and an external audience still degrades to `restricted`, while the channel projects neither its mode nor its session status |
404+
| Host modes M2-M3 | Attached-host parity, typed unavailability, and mode-aware projection with no mode inference and no second executor | Partly shipped: the channel's managed segment transport holds one executor per binding, refuses a second start with the typed `managed_host_chat_segment_in_flight`, and discards an interrupted segment's answer instead of letting it enter visible history. The channel readback also carries the mode-aware projection: it quotes the Session's own `session_mode` and `status`, reads a channel with no Session as `unbound`, and names a mode outside the closed set as `unrecognized` instead of deriving a mode from the executor it resolved. Still not implemented: attached-host parity, and an external audience still degrades to `restricted` |
405405

406406
Two boundaries stay fixed across all five rows. The channel remains an entry point
407407
and projection of one manager Session: it owns no profile, no permission state, no

‎docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ operator 凭据上,且管家通道自身不存在任何默认指向个人订
324324
| 管家 M2 — 语义续接 | 跨所有已注册运行中 lane 的接收者解析;按来源的 typed 覆盖与新鲜度;报告可以先用目标级里程碑开头,而不是先给覆盖免责声明 | 未实现。委托只按传入的委托目录解析,因此拥有该事项的 lane 不在目录中时会被拒绝或投给无关 lane;provider 读取失败以原始错误文本出现在回答里,而不是 typed 来源行;管家上下文只提供交付与覆盖,没有可综合的目标级里程碑字段 |
325325
| 管家 M3 — 自动完成一次交流 | 超出或违反通道出站文本契约的已保存回答,按稳定答案身份分片重发;含糊或失败的发送要协调而不是用本地提示替代;回传路径要能跨传输重启存活;富文本要渲染成结构化文本 | 部分缓解。`loopx/extensions/lark/outbound.py` 在超限或载荷不合法时 fail closed,通道只回报这个本地失败、不重新投递已保存的回答;一条回答没有幂等身份,重试可能重复发送;结构化渲染没有保证 |
326326
| 宿主模式 M0-M1 | 通道的执行器选型与其有界单段执行 | 选型由 PR #4446 覆盖,Turn 侧选型由 PR #4443 覆盖;有界单段执行由上面的 Mode B 验收覆盖。通道本身现在经单段传输抵达托管宿主,因此托管宿主自己的单段执行已可从通道抵达;仍未提供的是跨 turn 宿主连续性——片段不是会话 |
327-
| 宿主模式 M2-M3 | attached-host 对齐、typed 不可用,以及不做模式推断、不引入第二执行器的模式感知投影 | 部分已实现:通道的托管段传输为每个绑定只保留一个执行器,第二次启动以 typed `managed_host_chat_segment_in_flight` 拒绝,被中断段的回答会被丢弃而不会进入可见历史。仍未实现:attached-host 对齐;外部受众仍降级为 `restricted`,通道既不投影模式也不投影会话状态 |
327+
| 宿主模式 M2-M3 | attached-host 对齐、typed 不可用,以及不做模式推断、不引入第二执行器的模式感知投影 | 部分已实现:通道的托管段传输为每个绑定只保留一个执行器,第二次启动以 typed `managed_host_chat_segment_in_flight` 拒绝,被中断段的回答会被丢弃而不会进入可见历史。通道读回也带上了模式感知投影:引用 Session 自己的 `session_mode` 与 `status`,没有 Session 的通道读作 `unbound`,闭集之外的模式命名为 `unrecognized`,而不是从已解析的执行器反推模式。仍未实现:attached-host 对齐;外部受众仍降级为 `restricted` |
328328

329329
五行的两条边界固定不变:通道始终是同一个 manager Session 的入口与投影,不拥有
330330
profile、权限状态、第二执行器或工作权威,因此更丰富的回答契约不得扩大通道可读或

‎examples/loopx-steward-channel-binding-smoke.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
MANAGER_ENDPOINT_SOURCE_EXPLICIT_CONFIG,
2424
MANAGER_MODEL_SOURCE_MANAGED_PROFILE,
2525
MANAGER_MODEL_SOURCE_VENDOR_DEFAULT,
26+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_READBACK,
27+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNBOUND,
28+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNRECOGNIZED,
29+
manager_channel_session_mode_readback,
2630
manager_channel_binding,
2731
manager_executor_endpoint_default,
2832
manager_model_config,
@@ -233,11 +237,51 @@ def open_session(self, **kwargs):
233237
return str(opened[-1]["agent_id"])
234238

235239

240+
def _assert_mode_readback_quotes_the_session() -> dict[str, object]:
241+
"""The channel reports the mode it serves, and derives none on its own."""
242+
243+
unbound = manager_channel_binding({CREDENTIAL_ENV: CREDENTIAL_VALUE})
244+
_assert(
245+
unbound["executor_kind"] == "managed"
246+
and unbound["session_mode"] is None
247+
and unbound["session_mode_source"]
248+
== MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNBOUND,
249+
"a ready managed endpoint is not evidence that the channel is bound",
250+
)
251+
attached = manager_channel_binding(
252+
{CREDENTIAL_ENV: CREDENTIAL_VALUE},
253+
session={"session_mode": "attached_host", "status": "busy"},
254+
)
255+
_assert(
256+
attached["session_mode"] == "attached_host"
257+
and attached["session_status"] == "busy"
258+
and attached["session_mode_source"]
259+
== MANAGER_CHANNEL_SESSION_MODE_SOURCE_READBACK,
260+
"the channel must quote the Session's own mode, not the executor it resolved",
261+
)
262+
unrecognized = manager_channel_session_mode_readback(
263+
{"session_mode": "hybrid_handoff", "status": "ready"}
264+
)
265+
_assert(
266+
unrecognized["session_mode"] is None
267+
and unrecognized["session_mode_source"]
268+
== MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNRECOGNIZED,
269+
"a mode outside the closed set must be named rather than coerced",
270+
)
271+
return {
272+
"unbound_session_mode_source": unbound["session_mode_source"],
273+
"quoted_session_mode": attached["session_mode"],
274+
"quoted_session_status": attached["session_status"],
275+
"unrecognized_session_mode_source": unrecognized["session_mode_source"],
276+
}
277+
278+
236279
def main() -> int:
237280
payload = {
238281
"ok": True,
239282
"credential_default_probe": _assert_credential_decides_the_disclosed_default(),
240283
"explicit_selection": _assert_explicit_selection_and_managed_host_verdict(),
284+
"mode_readback": _assert_mode_readback_quotes_the_session(),
241285
"opened_endpoint": _assert_session_opens_the_resolved_endpoint(),
242286
}
243287
print(json.dumps(payload, ensure_ascii=False, indent=2))

‎loopx/chat_manager.py‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import hashlib
66
from pathlib import Path
7-
from typing import Any
7+
from typing import Any, Mapping
88

99
from .control_plane.operator_credential import (
1010
env_text,
@@ -19,6 +19,11 @@
1919
MANAGED_TURN_HOST,
2020
managed_executor_binding,
2121
)
22+
from .chat_store import (
23+
CHAT_SESSION_MODE_ATTACHED,
24+
CHAT_SESSION_MODE_MANAGED,
25+
RESUMABLE_SESSION_STATES,
26+
)
2227

2328
MANAGER_AGENT_GOAL_ID = "loopx-manager"
2429
MANAGER_AGENT_OBJECTIVE = (
@@ -207,8 +212,83 @@ def manager_executor_endpoint_default(environ: dict[str, str] | None = None) ->
207212
return selected_manager_executor_endpoint(environ)[0]
208213

209214

215+
# The channel's readback quotes the mode and the status of the Session it is an
216+
# entry point to. The execution-mode RFC makes the binding, not the endpoint,
217+
# the transport or the audience, the unit of mode ownership, so this projection
218+
# never derives a mode from the executor it resolved: a channel whose managed
219+
# endpoint is ready and whose Session does not exist is *unbound*, not
220+
# `managed_runtime`. A mode outside the closed set is named as unrecognized
221+
# rather than coerced into a mode the host may not have chosen.
222+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_READBACK = "session_readback"
223+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNBOUND = "unbound"
224+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNRECOGNIZED = "unrecognized"
225+
MANAGER_CHANNEL_SESSION_MODES = (
226+
CHAT_SESSION_MODE_MANAGED,
227+
CHAT_SESSION_MODE_ATTACHED,
228+
)
229+
230+
231+
def manager_channel_session_mode_readback(
232+
session: Mapping[str, Any] | None,
233+
) -> dict[str, Any]:
234+
"""Quote the channel Session's own mode and status into the channel readback.
235+
236+
``session`` is the store's public Session projection for this channel, or
237+
``None`` when the channel has none. The mode and the status are copied and
238+
only the source of the mode is decided here, so a reader can tell a quoted
239+
mode from an unbound channel instead of re-deriving the rule.
240+
"""
241+
242+
if session is None:
243+
return {
244+
"session_mode": None,
245+
"session_mode_source": MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNBOUND,
246+
"session_status": None,
247+
}
248+
session_mode = str(session.get("session_mode") or "")
249+
if session_mode not in MANAGER_CHANNEL_SESSION_MODES:
250+
return {
251+
"session_mode": None,
252+
"session_mode_source": (
253+
MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNRECOGNIZED
254+
),
255+
"session_status": None,
256+
}
257+
return {
258+
"session_mode": session_mode,
259+
"session_mode_source": MANAGER_CHANNEL_SESSION_MODE_SOURCE_READBACK,
260+
"session_status": str(session.get("status") or "") or None,
261+
}
262+
263+
264+
def manager_channel_session(
265+
store: Any,
266+
*,
267+
channel_id: str | None = None,
268+
provider: str = "",
269+
audience: str = "",
270+
) -> dict[str, Any] | None:
271+
"""Return the Session this channel would resume, or ``None``.
272+
273+
One channel is one ordered conversation, so the readback quotes its newest
274+
resumable Session. The store owns which states are resumable and projects
275+
the Session publicly; this function only selects, so the channel readback
276+
cannot widen what a Session exposes.
277+
"""
278+
279+
selected_channel = channel_id or manager_channel(
280+
provider=provider, audience=audience
281+
)
282+
for row in store.list_sessions(channel_id=selected_channel):
283+
if str(row.get("status") or "") in RESUMABLE_SESSION_STATES:
284+
return dict(row)
285+
return None
286+
287+
210288
def manager_channel_binding(
211289
environ: dict[str, str] | None = None,
290+
*,
291+
session: Mapping[str, Any] | None = None,
212292
) -> dict[str, Any]:
213293
"""Project the steward channel's resolved executor, model, and their source.
214294
@@ -224,6 +304,11 @@ def manager_channel_binding(
224304
A managed endpoint quotes the governed Turn surface's own executor readback
225305
for that verdict instead of deriving a second one, so the channel can never
226306
advertise an executor the Turn driver would refuse.
307+
308+
``session`` is the channel's public Session projection, when the caller has
309+
one. Its mode and status are quoted so a frontend can show which execution
310+
mode is serving the channel, and an absent Session reads as unbound rather
311+
than as a mode this projection guessed.
227312
"""
228313

229314
endpoint, endpoint_source, default_reason = _resolve_manager_endpoint(environ)
@@ -252,6 +337,7 @@ def manager_channel_binding(
252337
"unavailable_reason": unavailable_reason,
253338
"model": model,
254339
"model_source": model_source,
340+
**manager_channel_session_mode_readback(session),
255341
}
256342

257343

‎loopx/chat_server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
from .chat_runtime import ChatRuntimeController, TERMINAL_TURN_STATES
3333
from .chat_manager import (
3434
MANAGER_AGENT_GOAL_ID, MANAGER_AGENT_OBJECTIVE, is_manager_channel,
35-
manager_channel_binding, manager_workspace, manager_model_config,
35+
manager_channel_binding, manager_channel_session, manager_workspace,
36+
manager_model_config,
3637
)
3738
from .chat_session_open import open_chat_session
3839
from .chat_ssh_source_api import SshSourceRequestMixin
@@ -1260,7 +1261,9 @@ def do_GET(self) -> None:
12601261
"schema_version": "loopx_chat_capabilities_v1",
12611262
"manager": manager_runtime_capability_projection(
12621263
self.server.runtime_controller, manager_model_config(),
1263-
channel_binding=manager_channel_binding()),
1264+
channel_binding=manager_channel_binding(
1265+
session=manager_channel_session(self.server.chat_store)
1266+
)),
12641267
"runtime_identity": release_runtime_identity(),
12651268
"agent_backend": "multi_adapter",
12661269
"sandbox": "read-only",

‎loopx/semantics/inventory_v0.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@
904904
"python_closed_sets": 492,
905905
"python_literal_aliases": 8,
906906
"typescript_const_arrays": 40,
907-
"named_string_constants": 2051,
907+
"named_string_constants": 2054,
908908
"schema_version_names": 756,
909909
"schema_version_same_runtime_forks": 7,
910910
"cross_runtime_twins": 166,

‎tests/test_chat_server_cors.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import errno
44
import http.client
55
import json
6+
import tempfile
67
import threading
78
from pathlib import Path
89

@@ -11,6 +12,7 @@
1112
from loopx.chat_action_store import ChatActionStore
1213
from loopx.chat_actions import ChatActionService
1314
from loopx.chat_server import ChatHTTPServer, ChatRequestHandler
15+
from loopx.chat_store import ChatSessionStore
1416
from loopx.control_plane.effect_runtime import (
1517
EffectRuntimePermanentIOError,
1618
EffectRuntimeStartupError,
@@ -25,6 +27,9 @@ def _start_server() -> tuple[ChatHTTPServer, threading.Thread]:
2527
server.selected_goal_id = None
2628
server.registry_path = Path("/tmp/loopx-test-registry.json")
2729
server.runtime_root_override = None
30+
# The capabilities readback quotes the steward channel's Session, so a
31+
# fixture server carries the store the real startup always installs.
32+
server.chat_store = ChatSessionStore(Path(tempfile.mkdtemp()) / "runtime")
2833
server.scan_roots = []
2934
server.limit = 20
3035
server.runtime_controller = _RuntimeController()

0 commit comments

Comments
 (0)