Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .semgrep.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,30 @@ rules:
- "**/doeff_agents/sessionhost/impls/markers.hy"
- "**/sessionhost/impls/api_limit_possessive_verbatim_forbidden.hy"

- id: doeff-agents-herdr-session-identity-not-agent-name
languages:
- generic
severity: ERROR
message: >
herdr session identity must resolve through the workspace-label anchor
(workspace.list label match -> workspace_id -> pane.list /
workspace.close), never through the herdr agent-name registry
("agent.get"): herdr's real-agent detection overwrites the agent name
plate within ~2s of a real agent starting in the pane (probe
2026-08-01, n=3 deterministic), so name-based liveness/capture/kill
silently breaks for every session that actually runs an agent — the
exact regression behind issue #556 /
substrate-herdr-session-identity-anchor-r2-607f0c. Use
herdr-workspace-id-io (substrate_herdr.hy) or the label-based
out-of-band helpers (conformance/harness.py) instead.
patterns:
- pattern-regex: '"agent\.get"'
paths:
include:
- "**/doeff_agents/sessionhost/**"
- "**/doeff-agents/conformance/**"
- "**/sessionhost/herdr_agent_name_identity_forbidden.hy"

- id: doeff-agents-prompt-paste-must-be-ready-gated
languages:
- python
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/enforcement-ledger.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"_comment": "ADR-DOE-ENFORCE-001 R5 anti-drop ratchet の台帳。enforcement 資産の数が黙って減る(または黙って増える)ことを tests/test_enforcement_ledger.py が禁止する。数を変える変更は、この台帳の明示的な更新を同じ変更セットに含めること。",
"defadr_files": 22,
"semgrep_rules": 247,
"semgrep_rules": 248,
"adr_deftest_enforcements": 30,
"adr_defsemgrep_enforcements": 45,
"adr_laws": 72
Expand Down
128 changes: 72 additions & 56 deletions packages/doeff-agents/conformance/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,48 @@ def _herdr_call(method: str, params: dict[str, Any]) -> dict[str, Any]:
return json.loads(b"".join(chunks).decode("utf-8").strip())


def _herdr_workspace_order_key(workspace_id: str) -> tuple[int, str]:
"""Creation-order key for herdr workspace ids (shortlex).

herdr assigns workspace ids from a monotonically increasing base62-style
counter (probe 2026-08-09: consecutive creates got w1VS -> w1VT -> w1VV;
the oldest workspaces have the shortest ids, e.g. w3R), so shortlex order
is creation order while plain string order inverts it at digit
boundaries. Mirrors herdr-workspace-order-key in substrate_herdr.hy.
"""
return (len(workspace_id), workspace_id)


def _herdr_label_workspace_ids(label: str) -> list[str]:
"""Workspace ids holding the label, in creation order.

The herdr session identity anchor is the workspace label
(substrate_herdr.hy): real-agent detection overwrites the herdr agent
name plate within ~2s of a real agent starting in the pane (probe
2026-08-01, n=3 deterministic), so the agent-name registry cannot
address a live session out of band either.
"""
listed = _herdr_call("workspace.list", {})
if "error" in listed:
raise RuntimeError(f"herdr workspace.list failed: {listed['error']}")
return sorted(
(
ws["workspace_id"]
for ws in listed["result"]["workspaces"]
if ws.get("label") == label
),
key=_herdr_workspace_order_key,
)


def kill_session_out_of_band(session_id: str) -> None:
"""Backend-aware out-of-band session kill (S9 + harness teardown).

tmux: `tmux kill-session -t NAME`. herdr: resolve the agent name to its
pane over the socket (`agent.get {target}`) and `pane.close` it — herdr
has no name-addressed close. Both paths swallow "not found": the kill is
best-effort teardown / S9 fault injection, not an assertion.
tmux: `tmux kill-session -t NAME`. herdr: resolve the session name to
workspaces by label (the identity anchor) and `workspace.close` every
holder — teardown must also sweep a transient duplicate-race loser.
Both paths swallow "not found": the kill is best-effort teardown / S9
fault injection, not an assertion.
"""
if SESSIONHOST_BACKEND != "herdr":
subprocess.run(
Expand All @@ -112,20 +147,24 @@ def kill_session_out_of_band(session_id: str) -> None:
)
return
try:
got = _herdr_call("agent.get", {"target": session_id})
if "error" in got:
return
_herdr_call("pane.close", {"pane_id": got["result"]["agent"]["pane_id"]})
except OSError:
holders = _herdr_label_workspace_ids(session_id)
except (OSError, RuntimeError):
return
for ws_id in holders:
try:
_herdr_call("workspace.close", {"workspace_id": ws_id})
except OSError:
return


def session_exists_out_of_band(session_id: str) -> bool:
"""Backend-aware out-of-band liveness probe (load-bearing for cleanup
asserts: a rejected/failed launch must leave no mux session behind).

tmux: `tmux has-session -t NAME`. herdr: `agent.get {target}` resolves
the name; an error envelope means the agent/pane does not exist.
tmux: `tmux has-session -t NAME`. herdr: a workspace holding the session
name as its label exists (the identity anchor — the agent-name registry
is overwritten by real-agent detection, probe 2026-08-01, and would
report false for every session actually running an agent).
"""
if SESSIONHOST_BACKEND != "herdr":
probe = subprocess.run(
Expand All @@ -136,10 +175,11 @@ def session_exists_out_of_band(session_id: str) -> bool:
)
return probe.returncode == 0
try:
got = _herdr_call("agent.get", {"target": session_id})
except OSError:
return bool(_herdr_label_workspace_ids(session_id))
except (OSError, RuntimeError):
# Same parity as the pre-anchor probe: an unreachable server or an
# error envelope reads as "no session" for cleanup asserts.
return False
return "error" not in got


def break_pane_observation_out_of_band(session_id: str, pane_id: str) -> None:
Expand All @@ -151,14 +191,15 @@ def break_pane_observation_out_of_band(session_id: str, pane_id: str) -> None:
tmux: add a second window to the session, then kill the monitored pane —
the session survives through the new window (`has-session` true).

herdr: agent == pane (two layers, not tmux's session>window>pane three),
so a bare pane.close would delete the agent entry too and the liveness
check (agent.get) would fail first. Synthesize the same split state:
split a sibling pane, re-report the agent name onto the sibling
(pane.report_agent — same namespace as agent.start, measured 2026-07-07),
then close the original pane. agent.get then resolves to the sibling
(alive) while pane.read on the recorded pane_id fails. Errors raise:
this is fault-injection setup the test depends on, not best-effort.
herdr: liveness anchors on the workspace label (substrate_herdr.hy), so
the split state only needs the workspace to outlive the monitored pane:
split a sibling pane (the workspace keeps its label through it), then
close the original pane. The label still resolves (alive) while
pane.read on the recorded pane_id fails. (The pre-anchor synthesis also
re-reported the agent name onto the sibling because liveness used to
resolve through the agent-name registry; the label anchor removed that
dependency.) Errors raise: this is fault-injection setup the test
depends on, not best-effort.
"""
if SESSIONHOST_BACKEND != "herdr":
subprocess.run(
Expand All @@ -177,18 +218,6 @@ def break_pane_observation_out_of_band(session_id: str, pane_id: str) -> None:
split = _herdr_call("pane.split", {"pane_id": pane_id, "direction": "right"})
if "error" in split:
raise RuntimeError(f"herdr pane.split failed: {split['error']}")
sibling = split["result"]["pane"]["pane_id"]
reported = _herdr_call(
"pane.report_agent",
{
"pane_id": sibling,
"source": "doeff-conformance",
"agent": session_id,
"state": "idle",
},
)
if "error" in reported:
raise RuntimeError(f"herdr pane.report_agent failed: {reported['error']}")
closed = _herdr_call("pane.close", {"pane_id": pane_id})
if "error" in closed:
raise RuntimeError(f"herdr pane.close failed: {closed['error']}")
Expand All @@ -202,8 +231,11 @@ def create_session_out_of_band(name: str, *, cwd: str | None = None) -> str:
(`substrate.ref` for session.adopt / the turn descriptor's pane_id).

tmux: a detached session running the user's shell. herdr: the same
workspace.create -> agent.start -> root pane.close dance the sessionhost
herdr substrate performs (observed physics, substrate_herdr.hy).
workspace.create the sessionhost herdr substrate performs — protocol 17
(herdr 0.7.5) takes label/cwd directly and the root pane is the session
pane (substrate_herdr.hy; the protocol-14 agent.start -> root pane.close
dance is gone, and agent.start itself was reshaped into "start a managed
agent in an existing pane" and cannot create named shell panes).
"""
workdir = cwd or os.environ.get("HOME", "/tmp")
if SESSIONHOST_BACKEND != "herdr":
Expand All @@ -215,28 +247,12 @@ def create_session_out_of_band(name: str, *, cwd: str | None = None) -> str:
check=True,
)
return created.stdout.strip()
ws = _herdr_call("workspace.create", {"label": name, "focus": False})
ws = _herdr_call(
"workspace.create", {"label": name, "cwd": workdir, "focus": False}
)
if "error" in ws:
raise RuntimeError(f"herdr workspace.create failed: {ws['error']}")
ws_id = ws["result"]["workspace"]["workspace_id"]
root_pane = ws["result"]["root_pane"]["pane_id"]
started = _herdr_call(
"agent.start",
{
"name": name,
"cwd": workdir,
"argv": [os.environ.get("SHELL", "/bin/sh")],
"env": {},
"workspace_id": ws_id,
"focus": False,
},
)
if "error" in started:
raise RuntimeError(f"herdr agent.start failed: {started['error']}")
closed = _herdr_call("pane.close", {"pane_id": root_pane})
if "error" in closed:
raise RuntimeError(f"herdr pane.close failed: {closed['error']}")
return started["result"]["agent"]["pane_id"]
return ws["result"]["root_pane"]["pane_id"]


def resolve_agentd_bin() -> Path:
Expand Down
97 changes: 97 additions & 0 deletions packages/doeff-agents/conformance/herdr-physics.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,100 @@ S1 が flake(`report_result not accepted: []` — journal に report_result
増えた影響の可能性がある — orch 着地時に CI で再発するなら retry 予算の
再検討対象(契約自体の弱化はしない)。workspace churn のリークは無し
(12 テスト後の workspace list はデモ用 1 件のみ)。

## 追補: protocol 17(herdr 0.7.5)での agent.start 改形と名前登録経路(2026-07-29 実測)

観測対象: herdr 0.7.5 / protocol 17(client・server とも。`herdr status` で確認)。
実測手段: socket 直叩き probe + bundled schema(`herdr api schema --json`、
`$schema.schemas.request.$defs`)。契機: 既定 pytest の herdr smoke 5 本が
`HerdrApiError invalid_request: missing field 'kind'` で赤化(doeff issue #556)。

- **`agent.start` は params ごと改形された**。protocol 14 の
`{name, cwd, argv, env, workspace_id, focus}`(名前付き pane 生成)から、
protocol 17 では `AgentStartParams = {name, kind, pane_id}` 必須
(+ optional `args`, `timeout_ms`)の「**既存 shell pane への管理対象 agent
起動 + 検出待ち**」へ(CLI help: "Start a supported interactive agent in an
existing pane"。`kind` の語彙は pi/claude/codex/gemini/… の 21 種)。
旧 payload に `kind` を足しても `missing field 'pane_id'` で拒否(実測)。
未知 field(cwd/argv/env/workspace_id/focus)は黙って無視される。
→ shell pane の名前付き生成には**もう使えない**。
- **`workspace.create` が `cwd` / `env` を直接受ける**ようになった
(`WorkspaceCreateParams = {label?, cwd?, env?, focus?}`)。root pane が
指定 cwd の shell として起動し、env 注入も実効(`echo $DOEFF_PROBE` で確認)。
→ 専用 workspace の root pane がそのまま session pane になり、protocol 14 の
「agent.start → root pane close で全幅展開」ダンスは不要になった。
- **名前登録は `pane.report_agent` → `agent.rename` 経由**。plain shell pane への
`agent.rename {target: pane_id, name}` は `agent_not_found`。先に
`pane.report_agent {pane_id, source, agent, state}`(外部 authority で agent
エントリを作る。`agent` は type: string の自由文字列 — 任意値受理を実測)を
打つと rename が通り、`agent.get {target: name}` で解決できる。
重複名は rename が **`agent_name_taken`** をネイティブ拒否(protocol 14 の
agent.start と同じ error code = tmux duplicate 拒否 parity 維持)。
- ~~`pane.clear_agent_authority {pane_id, source}` 後も名前は terminal に残る~~
**訂正(2026-08-01 実測 — 下記追補)**: 名前が残るのは「実 agent が pane 内で
起動するまで」だけ。実 agent(claude)を起動すると 2 秒以内に herdr の
実 agent 検出が名札を上書きし、`agent.get {target: 旧名}` は agent_not_found
になる(probe n=3 決定的)。shell pane のうちは名前が残るため、agent 起動前
までしか見ないテストはこの破れを検出しない — 2026-07-29 時点の本記録は
観測範囲(shell pane のみ)の限界だった。state authority を画面検出へ返すと、
実 agent 起動後の状態分類・kind 付けとともに名札も herdr 側が付け直す。
- **kill parity 不変**: 唯一 pane の `pane.close` で workspace 自動消滅 +
agent 名簿からも消える(`agent.get` → `agent_not_found`、実測)。
- `pane.read` の語彙は不変(source: visible/recent/recent_unwrapped/detection、
format: text/ansi)。`strip_ansi`(default true)が増えたが、format=ansi +
自前 strip の既存経路は trailing space 保持込みで green(deftest で確認)。

実装への反映: `substrate_herdr.hy` の `herdr-new-session-io` を
`workspace.create {label, cwd, env, focus: false}` → `pane.report_agent` →
`agent.rename` → `pane.clear_agent_authority` に束縛替え(登録途中の失敗は
workspace.close してから再送出 — dup 拒否 parity の deftest green)。
**→ この名前登録束縛は 2026-08-01 の破れ実測(名札上書き)により session
同一性アンカーとしては撤回。現行アンカーは workspace label(下記追補)。**

## 追補: session 同一性アンカーの workspace label 移行(2026-08-01 / 2026-08-09 実測)

観測対象: herdr 0.7.5 / protocol 17。契機: PR #569(agent 名簿登録による
同一性)のレビュー中の実 agent E2E probe。issue
substrate-herdr-session-identity-anchor-r2-607f0c(#556 の系譜)。

- **agent 名簿は session 同一性を担えない**(2026-08-01 probe、n=3 決定的):
pane.report_agent → agent.rename → pane.clear_agent_authority で登録した
名札は、pane 内で実 agent(claude)を起動すると **2 秒以内に herdr の
実 agent 検出に上書きされ**、`agent.get {target: session 名}` が
agent_not_found になる。生死確認・帰属観測・kill の名前解決が全滅する。
shell pane のうちは名札が残るため、agent 起動前までしか見ないテストは
この破れを検出しない(見落としの構造)。
- **workspace label は実 agent 起動後も残存する**(同 probe + 2026-08-09
再確認): label は doeff が workspace.create で所有し、herdr の検出は
agent 名簿だけを書き換える。→ **session 同一性のアンカーを workspace
label に移行**(session = workspace、pane 集合 = pane.list {workspace_id})。
- **名札消失の決定的再現**(2026-08-09 probe、/tmp/probe-rename-607f0c.log):
検出と同じ API 列 `pane.report_agent`(別 source)→ `agent.rename` で
名札上書きと同型の状態遷移を合成できる。deftest
`test-herdr-identity-survives-agent-name-loss` の模擬はこれ。
- **herdr は label の重複をネイティブ拒否しない**(2026-08-09 probe): 同一
label の workspace.create は 2 つ目も成功する。tmux duplicate 拒否 parity は
doeff 側の **create-then-verify** が所有する(先に作ってから label 保持者を
数え、創出順最小でなければ自分を閉じて raise)。check-then-create の
TOCTOU 窓は「herdr daemon が create を直列化するため、後から作った側の
verify には先に作った側が必ず載る」ことで閉じる(根拠はコード近傍 —
substrate_herdr.hy herdr-new-session-io)。
- **workspace_id は base62 風カウンタで創出順に単調増加**(2026-08-09 probe):
連続 create が w1VS → w1VT → w1VV、番号 1 の古い workspace は w3R と桁が
短い。素の文字列比較は桁境界で創出順が逆転("w1VS" < "w3R")するため、
重複 gate の勝敗と複数一致の解決は shortlex(桁数優先)で比較する。
- **agent 名には invalid_agent_name 制約がある**(2026-08-09 実測): 小文字
開始・[a-z0-9_-]・1-32 文字。**workspace label は無制約**(60 文字・
大文字・記号入りを受理、workspace.list で解決可能)— 旧アンカーは herdr の
名前制約を doeff session 名へ暗黙に強制していた(label 移行の追加根拠)。
- **kill parity**: kill-session は label → workspace 解決の上
`workspace.close`(全 pane ごと破棄 = tmux kill-session parity。S19c 型の
sibling pane が残る workspace も取り残さない)。conformance harness の
帯域外経路(kill / liveness / S19c fault injection / adopt fixture)も
同アンカーへ移行済み(harness.py)。
- 回帰ガード: deftest `test-herdr-identity-survives-agent-name-loss`(名札
消失後の has-session / session-pane-ids / capture / send / kill)+
`test-herdr-duplicate-session-rejected`(doeff 側重複判定 — 名札消失後の
重複素通りを含む)+ semgrep
`doeff-agents-herdr-session-identity-not-agent-name`(sessionhost /
conformance での agent.get 名前解決の恒久禁止)。
3 changes: 2 additions & 1 deletion packages/doeff-agents/src/doeff_agents/sessionhost/adopt.hy
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
;;;
;;; 既に生きている席(pane)の事後登記。observation-only(koine 条項 2):
;;; substrate へ許される接触は実在確認(TmuxHasSession — herdr backend では
;;; substrate_herdr の agent.get に解決される substrate 中立 probe)だけ。
;;; substrate_herdr の workspace label 解決(herdr-workspace-id-io)に落ちる
;;; substrate 中立 probe)だけ。
;;; 変異 effect — キー送出・session 作成/破棄・FS 書き・配送 — はこの
;;; モジュールでは semgrep doeff-agents-adopt-must-not-mutate-substrate が
;;; 構造的に禁止する。
Expand Down
Loading