Skip to content

feat(agent_sys): the agent work system — specs, designs, and a running implementation - #132

Open
dorado269 wants to merge 25 commits into
mainfrom
dev.yihou.aiopt.all.1
Open

feat(agent_sys): the agent work system — specs, designs, and a running implementation#132
dorado269 wants to merge 25 commits into
mainfrom
dev.yihou.aiopt.all.1

Conversation

@dorado269

@dorado269 dorado269 commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Builds on the task_graph substrate already merged in #124. Adds the nine
remaining packages, their specs and designs, and a CLI that runs a task package
end to end.

Rebased onto main and squashed to a single commit. The base was moved from
dev.yihou.aiopt.task.graph to main; the 615 development commits collapse into
one. One of them (aec06fc, "all spec first version") carried no
Signed-off-by and would have blocked this PR on DCO — squashing dissolves it.

The claim

A task is a function, signature <handoffs, agent>. Quality is guaranteed by
standardising what crosses the boundary — the inputs and the outputs — rather
than by trusting the executor to be good.

Everything below follows from taking that literally.

What is here

package what it owns
spec_loader YAML task packages: schema, asset auto-discovery, --var expansion
handoff the unit of transfer, its content types and item schemas
validator what makes a handoff checkable, and how far a check can be trusted
agent the executor and the backend abstraction
closure the binding of the four
env_mgr everything the system does to the OS: zones, grants, confinement
monitor observation without authority over task state
cli agent-sys run --package <dir>
task_graph subgraphs, validation phases, and the fixes the first real runs exposed

Nine specs, nine designs, and
docs/interfaces.md — normative for everything
that crosses a module boundary, and the document that made the seams reviewable
before the code existed.

Two reference packages, examples/demo and examples/demo2, plus
examples/demo-broken as a deliberate negative. A workflow's specs live in a
task package outside this repository; these are the bounded exceptions, and a
test enforces that nothing else creeps in.

Verification

pytest agent_sys2051 passed, 3 skipped, 4 xfailed, on this exact tree
after the rebase. 154 test files.

Both reference packages run to completion through the CLI.

What review should be sceptical about

Written plainly, because it is what a reviewer would otherwise have to find.

  • A passing validator is not a working system. An earlier end-to-end run
    reported 14/14 tasks, ten validators PASS and exit code 0 over an artefact in
    which every produced solution scored zero. Every check added since carries a
    non-vacuity control — a case that must still fail — because an instrument only
    ever pointed at the good case proves nothing.
  • The permission system defaults to off in the last commit of this branch's
    history, by the repository owner's instruction, and the CLI prints a
    PERMISSIONS_DISABLED banner on every run saying so. A green run here is
    explicitly not evidence that the sandbox works.
  • env_mgr's remote path is designed and partly built, with zero production
    callers.
    Ssh and DockerExec exist and nothing constructs them. It is a
    build, not a debug, and it is not claimed as working.

Not in this PR

Remote execution, and the one-real-task package that exercises it. Those are the
next stage and are tracked in
docs/implementation-stage.md.

@dorado269 dorado269 changed the title all spec first version [draft]all spec first version Aug 25, 2026
Comment thread agent_sys/agent/docs/spec.md Outdated
| Revision | 1 — 2026-08-24 |
| Date | 2026-08-24 |
| Scope | What an executor declares, what its knowledge must contain, and the backend abstraction |
| Source | The task definition §6, §7.8; the Infera × Hyperloom kickoff report §2, §5F |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't refer to hyperloom, just use infera agent_sys

Comment thread agent_sys/agent/docs/spec.md Outdated

---

## 6. Logging

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logging belongs to another system "o11y". and logging is a independant package: it provide backend specific tools to save logs to json file with predefined schema. then some python post-process visitor will read it to, means the gen write process and the post process is decoupled, althrough some real-time post-process is allowed. also the log storage is managed by fs mgr and abey the permission rules system

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but you can append this part as a TODO to the tail of the plan. and we will do the spec, research, design, implement process for it later

Comment thread agent_sys/agent/docs/spec.md Outdated
──────────────────── ────────────────────────────────
Agent the instance AgentSpec the kind
.id name
.task_id permissions

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

permission is a runtime attr
and by default, agent is only allow to access its own in/out handoff, workspace, playground, log place.
actually, as task is strongly bind to a latest agent(main), and a task should have access permissions to all its subtask in its subgraph(if any) so let's say permissions is a versioned task attr, not agent.
and for a very simple first version the path in system is recursive and nested, so that the storage of sub-task is naturally in the parent's folder, that way, hook is easier to implement(you can check and analysis this logic)

Comment thread agent_sys/agent/docs/spec.md Outdated

## 1. Purpose

An agent is **one executor of one task run**. The system's own definition is

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just mention and clearify: agent spec is the very thing that wrap task spec. task spec is for input/output、goal template
it only abstract some common interface and property for backend/harness/sdk: for example :

  1. how to interact with agent, start, stop, interrupt, instruct, query.
  2. some config、rule、hook、skill.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simply put: it wraps every common interface that major/SOTA agent harness provide: claude code/cursor/codex. but let's start at the basic feature and let the extendibility to framework to ensure it, so that we can leave some implementation later and focus on phase 1 of this project.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, just rethink, maybe the "simply put" part is for the second level interface "ai agent", the first is just provide some unifined interface towards task runner of the aiopt system inside

Comment thread agent_sys/agent/docs/spec.md Outdated
### 1.1 In scope

- The YAML agent spec: permissions, environment, knowledge, backend.
- The four mandatory parts of an agent's knowledge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can first give knowledge four types(extendable):

  1. few shot
  2. runnable
  3. official reference
  4. expert experiment/suggestion
  5. suggested/verify resource source
  6. runtime gened: like senera analysis result, compile_command.json

@@ -0,0 +1,395 @@
# Agent — Specification

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an agent spec is bound to a task spec
and an ai agent has a beckend implementation list/dict like [key: claude_code_sdk, backend_entry: ...]

Comment thread agent_sys/agent/docs/spec.md Outdated
|---|---|---|
| 1 | **The executor is interchangeable** | AI, human, or program. The interface is the handoff, not the executor |
| 2 | **The agent node is coarse** | Whatever a backend organises internally is invisible and unmanaged. §4.1 |
| 3 | **No zero-shot** | Knowledge is mandatory and has four required parts. §3 |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not hardcode mandatory, but strongly suggestion with warning and a mandatory option for cli running config.
maybe there is a config dispatch and dissemination system, let make the first version very simple: a simple global yaml config with well classified partition, and everyone read it.

Comment thread agent_sys/agent/docs/spec.md Outdated

**Whatever multi-agent structure a backend organises internally is invisible to
this system, and explicitly not managed by it.** The system always considers the
work handed to a backend to have been done by one agent.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, from this part of system, and we gonna develop another module responsible for check and ensure at it's best effect every worker agent finish its job:
the monitor agent system, that is:

  1. every task, has it's monitor thread/runner to maintain the agent's status and a. answer the outside where it is, how far it goes, if it is alive, if it still in the right place. b. check
  2. normally, the task has it's own monitor. but also, the task append its monitor job to a global monitor who is round-robin-ly manage every task is allowed but when handling the monitor job for a task. it only has permission scope of that task (TODO: history of global monitor will leak context.)
  3. a monitor have several ways to handle a agent fail to submit it's handoff or stop/pause in the middle way: a. simplest way is a status checker with a simple phase "continue, do it until finish". b. better way is having a agent give simple analysis and dispatch among several decisions like: [push, help(assign a agent to help it ), add input/knowledge, create coordinator, create teamate/rival. change agent spec, escalate to upper scope, report to user]
    at first mvp version, we implement simple pusher

you can add this to TODO at the tail of plan

Comment thread agent_sys/agent/docs/spec.md Outdated
- **The audit record stays finite.** One `Execution` per run, whatever happened
inside.

The cost: a backend that spawns twenty subagents looks, from here, exactly like

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need, it's simply a interface related to o11y. but one thing we can ensure in a very lang term. although a backend agent cam report how many subagent it has, and make every agent history visible like claude code cli, Only interact with main backend agent is allowed[interrupt/talk...]

Comment thread agent_sys/agent/docs/spec.md Outdated
| `get_history()` | Observability (main spec §2 principle 4), and it is one of the three things an agent needs to be resumable (env_mgr spec §5) |
| `interrupt()` | Interventionability (§2 principle 5) |
| `append(message)` | The other half of intervention: steering a running agent without killing it |
| `set_rule(...)` | Injecting the system's rules and the task's constraints |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for a runtime interface. agent spec should be well prepared with its rules/hook and everything(defined and setup by env_mgr in env part?), the only thing is a helper to convert rules/hooks/skill among different format of different harness. but is another independent module: some thing like agent_harness_back_transform_helper.[let set the default store form as claude code]

Comment thread agent_sys/agent/docs/spec.md Outdated
| `append(message)` | The other half of intervention: steering a running agent without killing it |
| `set_rule(...)` | Injecting the system's rules and the task's constraints |
| `add_hook(event, handler)` | The enforcement mechanism for every boundary in the system that is not a scheduler boundary. §5.4 |
| `start(task, agent)` / `stop(...)` | The `TaskRunner` protocol `task_graph` spec §5 already defines. A backend satisfies it |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as task uuid is in runtime agent schema. the task parameter is not needed. but start_async should be provide and return a callback will be called when it really start. agent should have some status like pending、deploying、runing、..... something like that. I think the status of task should just be a supperset/wrapper of stack top's agent's status

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also a wait() candy and a synchronize start()[wrapper of async] candy can be provide.
for more interface. you can research claude code sdk and cursor sdk and tell me what are the most important/crticial interfaces

Comment thread agent_sys/agent/docs/spec.md Outdated
| `interrupt()` | `ClaudeSDKClient.interrupt()` — streaming mode only |
| `append(message)` | Streaming input: `client.query(prompt: AsyncIterable[dict])`, yielding `{"type": "user", "message": {...}}` items; or a further `client.query(str)` on the same session |
| `add_hook(event, handler)` | `ClaudeAgentOptions(hooks={HookEvent: [HookMatcher(matcher=..., hooks=[cb])]})` |
| permission gate | `ClaudeAgentOptions(can_use_tool=...)` for the prompt path; a `PreToolUse` hook to gate **every** call. §5.4 |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

about how permission and isolation of agent is implemented on SOTA harness[like claude code/cursor/codex], you should do deepresearch and gen a report. So that we can refer the SOTA design

Comment thread agent_sys/agent/docs/spec.md Outdated
| `permissions` | §3.2 |
| `env` | Environment requirements, resolved by `env_mgr` |
| `knowledge` | §3.3. **Mandatory** |
| `resources` | Default pool names and amounts a task using this spec declares |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resources is task level stuff?

Comment thread agent_sys/agent/docs/spec.md Outdated
2. An agent spec naming an unregistered backend, an unresolvable knowledge
handoff, or a nonexistent permission zone is rejected at load; each message
names the offending value.
3. **A backend satisfies `TaskRunner` unchanged.** The `claude-agent-sdk` backend

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backend and task runner is different thing. task runner is a mid-man between task、task-scheduler、agent, pure func/helper. for decouple purpose (you can argue about this design)
backend is a real-thing that run a common define "agent" like claude code agent after the env/workspace/handoff/playground is deployed

Comment thread agent_sys/agent/docs/spec.md Outdated
hook denies it; the denial is recorded at `warning`.
9. A system-level logging requirement cannot be suppressed by an agent's own
logging rules.
10. **Swapping the backend changes no other component.** The demo graph

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the agent related material is classified into two kind: with backend format(plugins、rule、hooks), no backend format(like knowledge)

  1. for with backend format, store it as claude code standard, and implement convert/transform helper

Comment thread agent_sys/agent/docs/spec.md Outdated

| Item | Status |
|---|---|
| **The human executor** | §2 principle 1 says a human may be an executor and nothing contradicts it, but no backend implements one. What a human backend's `get_history()` and `interrupt()` mean is undecided — the honest answers are probably "the notes they took" and "ask them to stop" |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just deploy. and the human will manually modify the status and provide handoff. that is: no initiative action like detect or cyclical action like heartbeat is allowed, the monitor is released form "most of its duty", only maintain the status when been called(or callback called), anyway there will be a human gui or something in the future, but is not our topic now, just ensure the design and interface supports that kind of extend.

Comment thread agent_sys/agent/docs/spec.md Outdated
| **The human executor** | §2 principle 1 says a human may be an executor and nothing contradicts it, but no backend implements one. What a human backend's `get_history()` and `interrupt()` mean is undecided — the honest answers are probably "the notes they took" and "ask them to stop" |
| **The observer** | Main spec §10 leaves this open system-wide. The agent-level part of it: an outside view of whether *this* agent has drifted from its goal or is looping. `get_history()` supplies the data; nothing consumes it |
| **Backend capability negotiation** | §4.2 lists required capabilities as though every backend has all of them. A backend that cannot interrupt is still useful; whether it declares that, and what the system does when a task needs a capability the backend lacks, is unspecified |
| **Cost attribution** | The SDK reports cost per session (`ResultMessage`, `max_budget_usd`). The system has a consumable token pool (`task_graph` spec §3.4) that expects a settled figure at completion. The mapping between them is a design-stage question, but the two have different notions of what a "run" is when a session is resumed |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

o11y system should also record some metrics per run of a agent spec, like cost, time cost, resume times, failed times...
mark as TODOs to as a subsystem of o11y

Comment thread agent_sys/agent/docs/spec.md Outdated
| **The observer** | Main spec §10 leaves this open system-wide. The agent-level part of it: an outside view of whether *this* agent has drifted from its goal or is looping. `get_history()` supplies the data; nothing consumes it |
| **Backend capability negotiation** | §4.2 lists required capabilities as though every backend has all of them. A backend that cannot interrupt is still useful; whether it declares that, and what the system does when a task needs a capability the backend lacks, is unspecified |
| **Cost attribution** | The SDK reports cost per session (`ResultMessage`, `max_budget_usd`). The system has a consumable token pool (`task_graph` spec §3.4) that expects a settled figure at completion. The mapping between them is a design-stage question, but the two have different notions of what a "run" is when a session is resumed |
| **Knowledge freshness** | §3.3 requires official references, and handoff spec §11 notes that knowledge handoffs outlive runs. Nothing detects that a referenced document has changed, which is precisely the staleness the requirement exists to prevent |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simply, knowledges has its versions/timestamp/checksum.
and there will be a knowledges record system being called everytime the knowledge storage is update.
but currently we don't care, just implement the version simple version.
mark it TO roadmap doc long-termly

Comment thread agent_sys/agent/docs/spec.md Outdated
| **Backend capability negotiation** | §4.2 lists required capabilities as though every backend has all of them. A backend that cannot interrupt is still useful; whether it declares that, and what the system does when a task needs a capability the backend lacks, is unspecified |
| **Cost attribution** | The SDK reports cost per session (`ResultMessage`, `max_budget_usd`). The system has a consumable token pool (`task_graph` spec §3.4) that expects a settled figure at completion. The mapping between them is a design-stage question, but the two have different notions of what a "run" is when a session is resumed |
| **Knowledge freshness** | §3.3 requires official references, and handoff spec §11 notes that knowledge handoffs outlive runs. Nothing detects that a referenced document has changed, which is precisely the staleness the requirement exists to prevent |
| **Subagent visibility** | §4.1 makes internal structure invisible by design. The SDK exposes it — `SubagentStart`, `SubagentStop`, `forward_subagent_text`, and `parent_tool_use_id` on session messages. Whether the system should stay blind or record a summary is a genuine design choice, not an oversight |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as we have the whole runtime thing harness provided to us, it's all about o11y with some visitor design pattern, so that is decoupled.
but currently we don't care,
mark it TO roadmap doc long-termly

Comment thread agent_sys/closure/docs/spec.md Outdated

### 1.1 It supplies no semantics of its own

**A closure is an abstract table — a wrapper — and nothing more.** This is the

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also can provide some candy wrapper query func candy

@@ -0,0 +1,178 @@
# Closure — Specification

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so it's spec level stuff

Comment thread agent_sys/closure/docs/spec.md Outdated

| Item | Status |
|---|---|
| **Closure versioning** | A closure is the recording. Changing one changes what "the same workflow" means, and nothing records which version of a closure a past run used. The `Execution` record names the agent and the handoff versions (`task_graph` spec §3.2) but not the closure |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

closure is a code management level/spec level thing, with some load checker, read only func candy. does this answer your question?

Comment thread agent_sys/closure/docs/spec.md Outdated
|---|---|
| **Closure versioning** | A closure is the recording. Changing one changes what "the same workflow" means, and nothing records which version of a closure a past run used. The `Execution` record names the agent and the handoff versions (`task_graph` spec §3.2) but not the closure |
| **Graph-level composition** | §4.1 leaves it out on purpose, and it has to live somewhere. The likely home is the system whole task (`task_graph` spec §3.2.1), which is the only thing that sees every closure in a graph |
| **Parameterised closures** | The reference workflow runs the same six steps against different models and hardware. Whether that is six closures with runtime parameters, or a closure per configuration, is undecided — and the answer interacts with main spec §6's static-graph constraint, since a parameter that changes the graph's *shape* is a dynamic spec by another name |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's different closures because its different task

Comment thread agent_sys/closure/docs/spec.md Outdated
| **Closure versioning** | A closure is the recording. Changing one changes what "the same workflow" means, and nothing records which version of a closure a past run used. The `Execution` record names the agent and the handoff versions (`task_graph` spec §3.2) but not the closure |
| **Graph-level composition** | §4.1 leaves it out on purpose, and it has to live somewhere. The likely home is the system whole task (`task_graph` spec §3.2.1), which is the only thing that sees every closure in a graph |
| **Parameterised closures** | The reference workflow runs the same six steps against different models and hardware. Whether that is six closures with runtime parameters, or a closure per configuration, is undecided — and the answer interacts with main spec §6's static-graph constraint, since a parameter that changes the graph's *shape* is a dynamic spec by another name |
| **Sharing between closures** | §5 criterion 7 permits sharing. Nothing says what happens when a shared handoff kind's spec changes: every closure using it is affected, and nothing enumerates them. The handoff registry's reverse index (handoff spec §8.2) answers the validator half of this; the closure half has no index |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

closure is a code management level/spec level thing, with some load checker, read only func candy, it's a compose of four part ? does this answer your question?

Comment thread agent_sys/closure/docs/spec.md Outdated
| Item | Status |
|---|---|
| **Closure versioning** | A closure is the recording. Changing one changes what "the same workflow" means, and nothing records which version of a closure a past run used. The `Execution` record names the agent and the handoff versions (`task_graph` spec §3.2) but not the closure |
| **Graph-level composition** | §4.1 leaves it out on purpose, and it has to live somewhere. The likely home is the system whole task (`task_graph` spec §3.2.1), which is the only thing that sees every closure in a graph |

@dorado269 dorado269 Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's defined in a task.
more clearify:

  1. a task contains a task graph or nothing(just it self, namely leaf task)
  2. every leaf task is bind to a agent runtime (maybe several agent spec/backend when choose to run)
  3. every task has its monitor, it can be a thread or a thread with a AI(not using agent term because its a special agent with no handoff/task system...) or a global thread roundrobinly manage it.
  4. maybe the task_scheduler can be improved: 1. has the ability to say: one task must start after another's status reach some point. 2. provide "togather/peer" way that ensure two agent is start togather. but mark is to the long-term road map

Comment thread agent_sys/demo/docs/spec.md Outdated

### 1.2 Out of scope

- **The reference workflow.** The six-step optimisation loop is what the system

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, but 1. the agent system and it's usage is decouple, its the very principle of the agent_sys. 2, actually everything should be decouple or keep easy able to decouple in long-term evolution and maintenance. its one of the best practice of spec towards agentic coding repo.

Comment thread agent_sys/demo/docs/spec.md Outdated

---

## 4. The CLI surface and output

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so another big job is to implement a cli interface for the whole system, which receive a global task. a config yaml, some cli options, and run the whole thing.
Oh.. a lot things to do really

Comment thread agent_sys/demo/docs/spec.md Outdated

| Item | Status |
|---|---|
| **What the demo's task actually does** | §2.1 is illustrative. Something small, verifiable, and not contrived is wanted — a real check over a real artefact, so the validator has something to be honestly `strong` about |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

research and choose/generate one by your self.

Comment thread agent_sys/demo/docs/spec.md Outdated
| Item | Status |
|---|---|
| **What the demo's task actually does** | §2.1 is illustrative. Something small, verifiable, and not contrived is wanted — a real check over a real artefact, so the validator has something to be honestly `strong` about |
| **Model cost** | One small `claude-agent-sdk` call per run is the intent, but the SDK's default is Claude Code's own tool set and system prompt, which is not small. Which model, which tools, and which effort level is a design-stage decision with a cost attached |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a problem. I will provide the claude api key and endpoint for you in config. the env_mgr should have a submodule to setup up claude code sdk for me,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cost is not a concern here, just go straight forward.

…g implementation

Builds on the task_graph substrate already in main. Adds the nine remaining
packages, their specs and designs, and a CLI that runs a task package
end to end.

The claim the whole system rests on: **a task is a function**, signature
`<handoffs, agent>`. Quality is guaranteed by standardising what crosses the
boundary — the inputs and the outputs — rather than by trusting the executor.

## What is here

| package | what it owns |
|---|---|
| `spec_loader` | YAML task packages: schema, auto-discovery, `--var` expansion |
| `handoff`     | the unit of transfer, its content types and item schemas |
| `validator`   | what makes a handoff checkable, and how far a check is trusted |
| `agent`       | the executor and the backend abstraction |
| `closure`     | the binding of the four |
| `env_mgr`     | everything the system does to the OS: zones, grants, confinement |
| `monitor`     | observation without authority over task state |
| `cli`         | `agent-sys run --package <dir>` |
| `examples/`   | `demo` and `demo2`, the two reference packages |

`task_graph` gains subgraph dispatch, validation phases and the fixes the
first end-to-end runs exposed.

## Verification

`pytest agent_sys` — **2051 passed, 3 skipped, 4 xfailed**.

Both reference packages run to completion through the CLI.

## Squashed

This replaces 615 commits from `dev.yihou.aiopt.task.graph`. One of them
(`aec06fc`, "all spec first version") carried no `Signed-off-by` and would
have blocked the PR on DCO; squashing dissolves it.

Signed-off-by: yihou <yihou@amd.com>
…ed list

The guard enumerated three roots by name — `examples/demo/`,
`examples/demo-broken/`, `examples/demo2/` — and its own comment said a
fourth "has to be argued for in this file", adding that widening it to
`examples/` "is a specification change and `spec-author`'s". Reported as
such; this is the specification change.

The enumeration bought one real thing: nobody adds a workflow package
silently. It cost the thing that overrules it — **there is no number of
examples at which a framework has enough**, so every example was a
specification amendment.

§4.3 gains the exception and states what it does not relax: an example is a
task package like any other, may use nothing an outside package could not,
and is never a dependency of this repository's suite. It also records that
an example may require real hardware, weights or credentials — that is a
property of the workload being demonstrated, and this guard neither loads
nor runs a package.

Criterion 5 amended, and it is not vacuous. Verified by control rather than
by reading:

- a `module: handoff` document planted at `agent_sys/validator/` — FAILS
- the same file moved under `agent_sys/examples/`                  — PASSES

`pytest agent_sys` — 2051 passed, 3 skipped, 4 xfailed.

Signed-off-by: yihou <yihou@amd.com>
The third example package, and the first whose subject is a real workload
rather than a synthetic one: serve `Qwen/Qwen3.6-27B` in **mix** mode
(one worker, prefill and decode together, one node, no RDMA) on
infera + sglang, hand the result over as a packup-shaped reproduction
kit, and have a second AI reproduce the bring-up from that kit alone.

Admitted by main spec §4.3 rev. 13, which makes `examples/` the exception
as a directory. It is the first example that needs real hardware — 8x
MI300X, 52 GB of weights — and §4.3 says that is a property of the
workload being demonstrated, not a defect. Nothing in the suite depends
on it: it is package data, imported by nobody.

What it shows that `demo` and `demo2` structurally cannot:

- a validator with `kind: ai` — both existing packages use `kind: ai`
  only for *agents*, and neither has an AI validator at all
- a validator that runs the artefact it is judging, rather than
  inspecting its shape
- two validators of deliberately different cost and strength on one
  kind: `check_packup_shape` (program, seconds, weak) and
  `check_reproduces` (ai, gpu-hours, strong)

Verified first-hand, and only this:

    python -m cli.main show --package agent_sys/examples/single_real_task
      2 tasks: 1 root and 1 subtasks
      main:       output validation runs 2
      serve_qwen: output validation runs 2

    pytest agent_sys -- 2051 passed, 3 skipped, 4 xfailed

**Not** verified: an end-to-end run. B4 has not been run green, and this
commit claims nothing about it.

Signed-off-by: yihou <yihou@amd.com>
@dorado269
dorado269 force-pushed the dev.yihou.aiopt.all.1 branch from 06af30d to 1a5b8c4 Compare August 30, 2026 15:04
The package was authored against one host and shipped that host's facts.
None of it is a secret, but all of it is wrong for anyone else, and two
items put a username into public history.

**Two `env` values become required variables.** `SRT_MODEL_PATH` defaulted
to `/data/<user>/...` and `SRT_IMAGE` to a locally-built tag that exists
nowhere in this repository. Where a site keeps 52 GB of weights, and what
its image is called, are facts about that site; a default is one machine's
answer shipped as everyone's. `${NAME}` with no `:-` is a load-time fault
naming the file, the line and the variable, so the package now says what it
needs:

    --var model_path=... --var image=...

`etcd_image` (a public tag) and `work_root` (`/var/tmp`) keep their
defaults, because neither is anybody's local fact.

**Six dangling references removed.** Four files pointed at
`scratch/single-real-task-2026-08/...` for their evidence. `scratch/` is
gitignored and never ships, so those were broken links in a public package.
Each now cites the normative document instead — `validator` spec §8.2 for
the environment rows, which is what the scratch note was measuring.

**Local claims generalised**, not deleted: "eight worktrees share this box"
becomes "a development host is usually shared", `/home` being NFS
root_squash becomes the class of trap rather than this host's instance.
The warnings survive; the host does not.

**`README.md` added.** `steps/serve.yaml` already cited one for the
`resources` gap and there was none, and both other example packages have
one. It records what the package does not do: no `resources` block (the CLI
composition root declares no pools, so one would name a pool that does not
exist), `repos:` read by nothing, and no throughput/latency/accuracy claim.

Verified:

    show, no --var  -> REJECTED, naming serve.yaml:51 and both variables
    show, --var x2  -> 2 tasks, 1 root, output validation runs 2
    grep for usernames / hostnames / scratch refs / the image tag -> none
    pytest agent_sys -- 2051 passed, 3 skipped, 4 xfailed
    ruff + whitespace + yaml parse -- clean

Still not verified: an end-to-end run.

Signed-off-by: yihou <yihou@amd.com>
…udget

The first end-to-end run of `examples/single_real_task` ended at exactly
1800.0 s with `run_complete: the run did not settle within 1800 s`, both
tasks still `running`, the handoff still `generating`, and neither
validator ever started. The agent was not stuck: it was part-way through
verifying its own `REPRODUCE.md` by running it from scratch, which is what
it was asked to do. Worse than the lost work — the agent is abandoned
rather than asked to stop, so the two containers and the eight GPUs it held
stayed held until a human removed them.

**This is the second time and the shape is identical.** `_settle`'s
docstring already records the first: the value was 300 s and it killed a
healthy model call six seconds after the agent finished writing. The reason
it keeps happening is structural — `holding` deliberately counts a model
call as progress so the 20 s stall branch will not fire on one, which makes
this deadline the *only* exit for a run that is working. Every time the
unit of work grew (a model call, then a 27 B bring-up) the ceiling silently
became an execution budget for the one case it was never meant to bound,
and reported a healthy run as a hang.

Three changes, and the third is the one that matters:

- `_SETTLE_TIMEOUT = 14400.0`, sized as `_settle`'s own docstring argues it
  should be — generous, because `stall_after` is what actually catches a
  broken run. A finishing run never reaches it; quiescence returns the
  moment nothing is in a phase.
- `--timeout SECONDS` on `run`, because how long a task legitimately takes
  is a property of the package, not of this file.
- **A test for the branch, which had none.** That absence is why it went
  wrong twice: nothing failed when the ceiling stopped being reachable, and
  a ceiling that never fires is indistinguishable from no ceiling until
  something hangs for ever. The new test holds a thread without escalating,
  so the stall branch cannot end the run, and asserts it is still bounded —
  by the value passed, not the default — and pins the default so a third
  quiet tightening has to argue with a test first.

    pytest agent_sys -- 2052 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
Measured on the first end-to-end run of `examples/single_real_task`. The
agent finished its work correctly and tore down; the seal was refused on a
separate defect; the monitor decided to push *continue, do it until
finished*; `PUSH_ATTEMPTED` was written to the store — and the message was
never read. The run then sat for **65 minutes** with both processes alive
at ~1 s CPU per 20 s and nothing written anywhere, until it was killed.

`mainloop` breaks out once the status is terminal (`:410-413`) and
`queue.Queue` is unbounded, so `_enqueue_instruction`'s `put` succeeded
into a queue with no consumer. Reproduced in isolation:
`scratch/single-real-task-2026-08/probe_push_after_settle.py` —
`raised=None, inbox=1, delivered=[]`.

**The defect is the silence, not the failed push.** A push arriving after
the agent has settled is an ordinary race the monitor cannot win from
outside. Reporting it as attempted and then waiting for ever for an answer
that nothing will produce is not. The store made the two
indistinguishable: it holds `push_attempted` and no counterpart, so the
event reads the same whether the message landed or vanished.

**Raising is the sanctioned channel, not a new one.** `monitor/base.py`'s
`_push` writes `PUSH_ATTEMPTED` *before* the call precisely so that "an
`instruct` that raises still leaves the attempt visible" (criterion 9), and
`_run_guarded` catches it and records `handling_failed`. The caller was
already built for this; the path simply had no way to be taken. No
cross-module signature changes.

**The predicate is `_settled`, and the suite corrected me on it.** A first
version keyed on `_looping` and turned `test_instruct_does_not_end_run`
red: `start_async()` → `instruct()` → `mainloop()` is a shipped, legitimate
order in which no loop is turning and the message is nonetheless about to
be consumed. "Has not started yet" and "has already finished" are different
states and only the second is a fault.

Three tests, because one of them alone would be misleading:

- the defect: instruct after settlement raises, nothing is queued;
- **non-vacuity**: instruct during a live loop still reaches `_deliver` — a
  refusal that fired always would pass the first test and break the
  feature;
- **the state the suite taught me**: instruct before the loop starts is
  still queued and still delivered.

One race is named in the docstring rather than papered over: `mainloop`'s
`finally` clears `_looping` before it calls `_settle`, so a call landing
between those two lines is still lost. That window is a few instructions
wide and its outcome is the old behaviour, not a worse one.

    pytest agent_sys -- 2055 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
`_URL` already decided that an API path is not a filesystem path, and keys
on the scheme. An HTTP request-line carries the request-target **bare** —
`"POST /v1/chat/completions HTTP/1.1"` — so the mitigation did not fire and
`_CANDIDATE` read three segments as an absolute local path.

Measured 2026-08-31: this refused a **correct** handoff. The task's brief
ordered its agent to prove the completion had gone through the router
rather than the engine's own port; the natural evidence is the router's
access log; the seal rejected the artefact at `README.md:42`. Every correct
kit for that task contains the string, so the check refused the right
answer every time rather than occasionally.

This completes `_URL`'s own decision rather than making a new one, and it
does not touch criterion 17.

**Anchored on `HTTP/x.y`, not on the verb.** A first version matched
`VERB SP /path` alone and an automated review was right that it opens a
cloak: `POST /home/someone/run3` is a real local path that a bare-verb rule
suppresses, so anyone writing a verb before a path escapes the check.
Requiring the version token means only a genuine request-line is stripped.
**Not** anchored to start-of-line, which was the other half of that
suggestion — the measured line is
`INFO: 127.0.0.1:56726 - "POST /v1/chat/completions HTTP/1.1" 200 OK`, with
the request-line inside a quoted field mid-record, so `^` would have
reverted the fix it was meant to protect. Only the target is blanked, so a
real path beside a request-line still fires. The strictness costs two extra
false positives over the whole artefact (678 vs 676) and closes the cloak.

**It does not unblock that kit, and this commit does not claim it does.**
Measured over the real artefact:

    778 -> 678 occurrences (46 distinct, unchanged)
      618 still flagged are container-internal (/sgl-workspace/, /tmp/aiter_configs/)
       35 are genuinely local and SHOULD fail
        6 are API paths in shapes no method rule reaches —
          `` `GET` on the router's /v1/workers ``, `{router}/v1/chat/completions`

Those are the module's docstring being right: the shape refinement was
refused on Debian #1002451 because *"you cannot recognise a build path by
its shape"*, and each patch reveals the next shape. So the residual is
stated rather than chased — a local path forged as a complete request line
is still suppressed — and what bounds it is measured: **the certain half is
not cloakable.** `_scan_text` runs the oracle loop over the raw line before
any stripping, so a path this system minted fires anyway; only a non-minted
one escapes, on the half that already declares itself best effort.

The root cause is unwired mechanism, not regex: `Oracles` is constructed
nowhere in production (`store.py:140` falls back to `store_root` alone) and
`image_prefixes` — spec §7's mechanism for a declared image — is read at
`locality.py:151` and **set by nobody**, with `handoff.schema.json`'s
`dependencies` deliberately unconstrained, so wiring it means choosing a
convention. Written up in
`scratch/single-real-task-2026-08/seal-refusal.md`.

Four tests, and the last two are why the first two are worth anything:

- the access-log line no longer trips the check;
- **non-vacuity**: a genuinely local path still raises, and a line that
  merely *contains* a request-target is not blanket-suppressed;
- a bare verb with no version token cannot cloak a path;
- the forged-request-line residual, with both halves asserted — an
  oracle-minted path fires through the forgery, a non-minted one does not.
  The gap is pinned so it cannot close by accident and go unnoticed.

    pytest agent_sys -- 2059 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
…t P2

User-ruled 2026-08-31. `store.py` no longer calls `locality.check`, so
**`handoff` spec criterion 17 is not enforced today**. The module and its
twenty tests are kept intact and correct — this is a disconnected caller,
not a deleted module, and re-wiring it is one line.

It refused a correct artefact, and would have refused every correct one.
The first end-to-end run of `examples/single_real_task` was rejected at
`README.md:42` on a quoted HTTP access-log record proving the completion
went through the router rather than the engine's own port — which is
criterion 2 of that task's own brief. Measured over the produced kit: 778
flagged occurrences, ~97% false positive (618 container-internal, 106
request-targets, 10 an etcd key prefix, 35 genuinely local). That
reproduces the module's own docstring measurement on a second corpus.

More regex is not the fix. `locality.py` already records that the shape
refinement was proposed on Debian #1002451 and refused on the record —
*"you cannot recognise a build path by its shape"* — and the two patches
made earlier today each revealed the next shape.

**The wiring was never tested, and that is how it survived.** Disconnecting
the call changed no test result: 2059 passed before and after. All twenty
locality tests call the function directly; nothing puts or seals content
with a local path and asserts refusal. Same shape as three other findings
from today — `Oracles` constructed nowhere, `image_prefixes` set by nobody,
`env_mgr`'s `Ssh`/`DockerExec` with no production caller. Recorded in the
roadmap entry, because a rebuild without a wiring test leaves the next
inversion just as invisible in either direction.

What is lost meanwhile is stated rather than left to be discovered: the
check was aimed at exactly the right thing, and this stage's own mission —
*a second AI reproduces the run from the kit alone* — is the case it was
built for. The kit that triggered this bakes in a model path 20 times,
which is a true positive it would have caught.

Criterion 17 says NOT ENFORCED in the spec itself, not only in the roadmap:
a criterion a reader believes is enforced, and is not, is worse than one
that says so.

    pytest agent_sys -- 2059 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
An automated security review proposed the obvious refinement to the
disconnect in 5e1e58c: keep the oracle branch, which is sound and cheap,
and suppress only the shape heuristic. Right instinct; the measurement
refutes it, so the evidence is recorded rather than the argument.

Two corrections, both first-hand:

- **The oracle branch is live, not unwired.** `store.py:140` supplies
  `store_root`. Only `playground_root` is missing. An earlier claim in this
  entry's neighbourhood was too broad.
- Run against the real kit with that oracle supplied, it yields **two
  hits**, and both are genuine local paths. Two instead of 778 — and a
  refusal is still a refusal. This run would have been blocked either way.

The two hits are the interesting part. They are in
`logs/run_all.second-run.out`, where the agent's own log records where it
wrote — under the store root. So the task brief's *"capture your logs as
evidence"* and spec §7's *"a handoff carries no path from the machine that
produced it"* are in **direct conflict**, and this artefact satisfies both
briefs while failing the check.

Soundness cannot resolve that; the oracle branch is already exact. A
rebuild has to answer it, plausibly by weighting file role — which
`check()`'s own docstring already carries as an open question, `design.md`
O5: *"No weighting by file role … a playground path in a changelog is still
a record of one machine."*

No code change: the measurement says the full disconnect was necessary
rather than merely convenient.

Signed-off-by: yihou <yihou@amd.com>
…exist

`Context.mapping` was `{}` in every production run this repository had ever
made. `cli/main.py` built the `Context` without consulting `env_mgr.meta`, so
`prepare.py:423`'s `if ctx.mapping:` was never once true and `sync.sync`,
`sync.remote_root` and the `_REMOTE` half of `paths.zone_env` had no production
caller at all -- four mechanisms, all unit-tested, all green, all wired to
nothing. The configuration route they need already shipped: `--meta`, then
`$ENV_MGR_META`, then `~/.config/env_mgr/meta.json`, read by `env-mgr
domain|zone` since those sub-commands existed.

`cli/main.py:467` now reads it and passes `Meta.mapping_roots()` as `mapping=`.

Signatures, both sides named (`docs/interfaces.md` §1.1):

- `env_mgr.meta` gains `configured_path(explicit=None)`, the resolution order
  lifted out of `inspection._DEFAULT_META` / `inspection._meta` now that it has
  a second reader. `inspection` calls it instead of keeping its own copy; the
  two sides are `env_mgr/meta.py` and `env_mgr/inspection.py`. Resolved per
  call rather than at import, which `_DEFAULT_META` was not. One behavioural
  difference: `ENV_MGR_META=""` now falls through to the default instead of
  loading `""`.
- No signature changes anywhere else. `build_context` already took `mapping`;
  `main()` simply stopped declining to pass one. `Context`, `sync.sync` and
  `Connection` are untouched -- a same-machine mapping needs no transport, and
  a cross-host one needs a `Connection` on `Context`, which is not here.

Empty stays the default and the shipped behaviour: with no meta file the result
is `{}` and every existing run is byte-for-byte the run it was. That is asserted
rather than claimed -- `test_a_run_with_no_meta_file_maps_nothing`.

Tests are of the wiring, not only of the functions, because unit coverage with
no wiring coverage is precisely what let the four dead mechanisms survive:

- `tests/cli/test_remote_mapping.py` drives `main()`'s own `_registry` and reads
  the mapping back off the `Context` its `EnvManager` is bound to. Reverting the
  two lines in `cli/main.py` fails it; verified.
- `tests/env_mgr/test_prepare.py` gains the far side as an *artefact*: a mapped
  `prepare` puts the zone's files on the far side with their bytes, and an
  unmapped one does not create the far side at all. `SyncReport.sent` is parsed
  out of rsync's own summary and would read the same for a copy that landed
  somewhere else, so the assertion is on the directory.

Each test ships with the case that must still fail: no meta file maps nothing, a
*strong* mapping in the meta file is dropped by `mapping_roots()`, and an
unmapped `prepare` leaves no far side.

`pytest agent_sys`: 2063 passed, 3 skipped, 4 xfailed.

Signed-off-by: yihou <yihou@amd.com>
Measured 2026-08-31, mid-run: the agent called
`Skill{"experiment-result-packup"}` — which its own brief calls *"the
authority"* for the packup layout, and which the mission names as a
requirement — got `Unknown skill`, and started `find / -name ...` hunting
the directory by hand. Its zone's `config/` held the CLI's runtime
directories and no `skills/` at all.

**The mechanism was wired, working, and no package had ever used it.**
`env_mgr` redirects `CLAUDE_CONFIG_DIR` into the zone (`material.py`), a
session reads personal skills from `$CLAUDE_CONFIG_DIR/skills/`, and
`material.deploy` copies `MATERIAL_KEYS` — so a skill arrives only if the
agent spec declares one. None did: `grep` finds no `skills:`, `rules:` or
`hooks:` in any shipped package. That is the fifth mechanism found this way
today, and the first that was not broken — only unused.

Two changes:

- **`examples/single_real_task`** declares it, required and with no default
  for the same reason `model_path` is: where an operator keeps their skills
  is a fact about that operator, and a default would put one home directory
  into a package that is data. Omitting it now fails at load naming
  `serve.yaml:73` and the variable.
- **`material.deploy` raises** on a declared material that does not exist.
  It was `if os.path.exists(src): copy_out(...)` with no else, so a wrong
  path was skipped in silence and the agent met the absence hours later,
  inside its own session, as a failure of its own — with nothing in the
  zone, the events or the logs naming the cause. `fail closed` is this
  package's own rule (`locality.py`: *"an oracle whose prefix cannot be
  formed is an error, not a silently widened blind spot"*).

The second is the non-vacuity control for the first, and it is why the
first is worth anything: **with the declaration added and the guard
missing, a typo in the path produces a run that looks fixed and is not** —
the same bug wearing a fix. Both tested, and the acceptance evidence for
the skill is `<zone>/config/skills/experiment-result-packup/SKILL.md`
existing, not a run exiting 0.

That `$CLAUDE_CONFIG_DIR/skills/` is read at all was measured rather than
assumed, with a skill whose name cannot come from the model's prior:
`scratch/single-real-task-2026-08/r0_probe_skill_in_config_dir.sh`.

No shipped package declared any material when this landed, so nothing
existing changes behaviour. No cross-module signature changed.

    pytest agent_sys -- 2065 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
Two runs died to the absolute deadline this way, one after 65 minutes, and
neither was doing anything. Measured on a refused seal: the leaf's body
exited 0, its output was refused, it escalated **to its parent** with no
`target`, and its attempt thread stayed `is_running=True` for ever. The
root then escalated to the user. So `holding` was permanently 1 —
contributed by a task that was parked rather than working, whose own
escalation carried no `target` for `_awaiting_a_decision` to find — and the
20 s stall branch could not fire whatever else the graph did.

Instrumented rather than reasoned about
(`scratch/single-real-task-2026-08/probe_settle_view.py`), steady state:

    main             _is_running=False  _awaiting='nothing to push: …'
    emit_local_path  _is_running=True   _awaiting=''
    => holding=1   (stall fires only when 0)

**The fix checks the graph, not the task.** `_run` installs `NullUserSink`,
whose `deliver` appends to a list and returns — *"how a monitor reaches a
human is unspecified anywhere in this system"* — so an escalation that
reaches the top **in this entry point** is terminal by construction. If
somebody is blocked on an answer nobody will give, it does not matter which
task still holds a thread: whatever it holds it for cannot arrive.

The two endings are now different messages, because they are different
facts a reader acts on: *the graph stopped making progress* is a graph that
died; *X is waiting on a decision no one will make* names the task, the
reason, and that a human was asked for something this entry point cannot
deliver. Reporting the second as the first is how it read as a hang twice.

Measured end to end on the repro package, no GPU:

    before   EXIT=124, killed at the timeout, 7 events in 3.4 s then silence
    after    EXIT=5,  32 s, naming the task and the escalation
    control  EXIT=0,  13 s, validator ran and PASSed — unchanged

Two tests, and the second is why the first is worth anything:

- the defect: an escalation to the user on the **root** ends the run while
  the **leaf** still holds a thread, and the message says which ending it
  is;
- **non-vacuity**: the same shape with no user escalation must **not** end
  early. A rule that ended any run with a task holding a thread would pass
  the first test and break every real run — an AI leaf mid-model-call holds
  one for minutes, which is the case `holding` exists to protect and which
  this file has already got wrong once.

    pytest agent_sys -- 2067 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
…stant

`run-watchdog` found the B5 kit starting its containers with hardcoded names --
and `01_start_etcd.sh` / `02_start_container.sh` do not merely reuse them, they
run `docker rm -f "${NAME}" >/dev/null 2>&1 || true` first. So a name clash does
not fail. It takes: the kit deletes whatever holds the name, swallows the result
and the message, starts its own container, and proceeds normally.

Two consequences, and the second is why this qualifies B5's result:

- Two ordinary runs, no validator. The second operator's `02_start_container.sh`
  deletes the first's serving container mid-flight. No error in the terminal, no
  error in the log -- the log file is gone with the container -- and they would
  reasonably conclude the engine crashed. Nothing in the kit prevents it.
- `check_reproduces` against a run that has not torn down kills the agent's
  still-running server and then reports PASS, having destroyed the thing it was
  supposed to be independent of. A false PASS in the one validator whose entire
  job is independent corroboration.

B5 was green because the agent tore down cleanly first: a timing property
holding by luck, not an isolation property holding by construction.

The brief already told the agent to pick its ports. It said nothing about names,
and three things made that gap wider than it looks. The port check does not
protect the names, and the careful path is the dangerous one -- a reproducer who
runs `check_ports.sh`, sees BUSY, edits the ports as instructed and proceeds is
past the only guard and walks into step 2's `docker rm -f`. The workdir survives
fixing the names: `WORK` has a fixed leaf and is bind-mounted as `/workdir`, so
two runs share one `results/`, which is where the verification script writes its
evidence -- a verifier can read the other run's evidence and pass on it with
distinct container names. And nothing below the four inputs in `00_env.sh` is
overridable: names, ports, GPU and workdir are plain `export X=literal`, so the
only escape hatch is editing the file, which is fine for a human doing it once
and unavailable to the one consumer guaranteed to run it concurrently.

So the ports bullet becomes one rule over every host-shared identifier, in three
parts -- do not assume, let the kit be re-pointed without editing it, and never
take a name you did not create -- with the workdir called out separately,
because a reader who fixes the names will otherwise think they are done. Each
part names its failure mode rather than only its rule. The teardown bullet now
enumerates names, ports, GPUs and workdirs, and says that tearing down makes two
runs safe in time while the rule makes them safe in space: you need both,
because you do not control when the reproducer starts.

**Why the brief and not the engine.** The engine has a per-run id and it is the
wrong id -- it names the *authoring* run, while the collision is between that
run and a *later* reproducing one. Worse, an engine-supplied token would reach
the agent at authoring time and be baked into `00_env.sh` as a literal, exactly
as `srt_qwen36_mix` is now, so both runs would share it: that moves the constant
rather than removing it. For any external token to work the identifiers must
already be `: "${VAR:=...}"`-shaped and read from the environment at reproduce
time -- and once that holds, no token is needed, because `check_reproduces` can
export whatever it likes. An engine change is therefore useless without the
brief change and redundant with it, which is why there is no second naming
authority: the operator's environment is the authority, and it is free.

The B5 kit under `/var/tmp/srt_b5` is left unmodified. It is that run's
evidence, and comparing the next run's kit against it is how we find out whether
this wording worked.

`pytest agent_sys`: 2067 passed, 3 skipped, 4 xfailed -- unchanged.

Signed-off-by: yihou <yihou@amd.com>
Measured 2026-08-31 across two real runs: `stream.jsonl` carried five
events in the first 150 ms, then **nothing for forty minutes**, then a
burst at the end. `_run` emits at `_start` and again at `_report`, and
execution happens between them. The store meanwhile carried
`output_absent`, `push_attempted` and `escalated` — a different `EventKind`
with no bridge to here. The two kind-sets were **disjoint**, and the stream
stopped three milliseconds before the store started.

The cost was not cosmetic. A healthy 40-minute run and a run that
deadlocked after four seconds produced *the same file* until one of them
ended, so every defect found that day was found by a human reading an
agent's transcript rather than by reading the run's own output. It is also
why no liveness check could be built on this file: "no events for N
minutes" would have fired on the healthy run too.

`_settle` already polls the whole graph every tick and already diffs it. It
knew what had moved and did not say. So this says it, in vocabulary
`cli/events.py` already declares — `PHASE_START` and `PHASE_COMPLETE` had
no emit site anywhere in the package — with no new mechanism and nothing
crossing a module boundary. `live=True` distinguishes these from
`_describe`'s post-run pair, which reports the validation plan rather than
an observed transition.

    before   5 events: setup burst, silence, teardown burst
    after   19 events, 4 of them during execution:
              phase_start     main: input_validating -> running
              phase_start     emit_local_path: (new) -> input_validating
              phase_start     emit_local_path: input_validating -> running
              phase_complete  emit_local_path: running -> succeeded

**Caught by running it, not by the suite.** The first version called
`task_mgr.get(tid)` where `_snapshot` stores `str(t.id)` and `get` takes a
`TaskId`; the `KeyError` took the settle loop — the run's last thread —
with it, turning a reporting improvement into a crash. Now built from
`all()`.

**What this does and does not buy, stated because the difference matters.**
It makes silence during execution *meaningful*: before, execution was
always silent, so silence carried no information at all. It is not a
liveness solution — a task legitimately mid-model-call still emits nothing
for minutes. And the stream still does not carry the store's kinds, so it
now shows *that* a task stopped moving and never *why*; bridging the two
`EventKind`s is a seam decision and is not this.

    pytest agent_sys -- 2067 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
…a shared host

The prose version of this rule already failed once and we have the artefact. The
task brief said "Pick your ports; do not assume them... record the ports you
actually used" **before** B5 ran, and B5's kit hardcodes all four ports, both
container names and the workdir. So a rule the producer is asked to honour with
nothing checking it is worth what that run showed it to be worth, and this
system's claim is that quality comes from standardising what crosses the
boundary rather than from trusting the producer.

`check_shared_identifiers` scans `scripts/` and collects two facts. A name is
**frozen** if it is assigned with a plain `X=` / `export X=` whose value still
contains an alphanumeric once every `${...}` and `$X` reference is removed. It is
**bound** if it reaches `--name`, `--publish`, `--volume`, `--mount`, `--port`,
`-p` or `-v`. A fault needs both, and any `${X:=...}` / `${X:-...}` / `${X:?...}`
anywhere in the kit exempts the name -- that is all it takes to let a caller
re-point it. Separately, a bare literal at `--name`, `--publish`, `--port` or
`-p` is a fault on its own.

Three decisions, argued in the readme beside the body:

- The shape is inspected, never the value. `"${HOST}:${PORT}"` is built out of
  other variables and is not frozen; `"${WORK_ROOT}/srt_qwen36_mix"` has a fixed
  leaf and is -- which is what catches the workdir, the one identifier that
  survives fixing the container names.
- No attempt to recognise "a container name" from its text. That is a heuristic
  over values with no stopping condition. What is recognised is a flag, which is
  a fact about the command being run.
- `-p` and `-v` count only inside a command mentioning `docker`, because
  `mkdir -p` appears in this very kit; and `--volume` is outside the
  bare-literal rule, because a read-only mount of an input path is legitimately
  fixed and only its host side is shared at all.

**The non-vacuity control is B5's own kit and it is the whole point.** Verbatim,
it FAILS on five identifiers -- `CTR_NAME`, `ETCD_NAME`, `ROUTER_PORT`,
`ENGINE_PORT` and `WORK`. A copy differing only in `00_env.sh`, and there only in
`export X=v` becoming `: "${X:=v}"`, PASSES. `SRT_MODEL_PATH` is already
parameterised and is correctly not reported, so the read-only model mount is not
a false positive. Both copies and the probe that runs them are kept in
`scratch/single-real-task-2026-08/`; the original under `/var/tmp/srt_b5` is that
run's evidence and was not modified.

The rule also gets a test, because answering "a rule nobody checks is worth
nothing" with a check nobody checks would be a poor joke. Eight cases in
`tests/cli/test_packup_shape_rule.py`, each carrying the case that must still
pass beside the one that must fail.

What it cannot catch is listed in the readme rather than left implicit: a port
embedded in a URL (B5's own `ETCD_CLIENT_PORT` reaches `--listen-client-urls`,
not `--port`), the GPU index (an environment variable, not a flag), a name
computed at runtime, and anything outside `scripts/` or not shell. All four are
false negatives; the two conjuncts and the generous exemption mean there is no
configuration under which a clean kit is reported.

`docker rm -f <name>` before `docker run --name <name>` -- the destructive act,
and the sharpest of the brief's three sub-rules -- is deliberately **not**
checked, and the reason is structural. The PASS control above is B5's kit with
its assignments rewritten, and it still contains its two `docker rm -f` lines. A
check that also refused those would refuse the control, which would mean the two
rules were measured together and neither isolated. It is a second check with its
own control, and it is not written yet.

Kept from drifting: the `brief:` line in `steps/serve.yaml` now says the
validator refuses a fixed identifier, `args` carries a comment saying there is a
third rule that takes no argument and why the flag set is not a dial, and
`FLAG_USE` is built from `BINDING_FLAGS` rather than repeating it.

`pytest agent_sys`: 2075 passed, 3 skipped, 4 xfailed -- 2067 plus the eight.

Signed-off-by: yihou <yihou@amd.com>
…you let it

Two things from the B6 run, neither a code change.

**ROADMAP 6.5, P1 — the stream cannot tell a working phase from a wedged
one.** `8b4b3ff` fixed half of it: `_settle` now emits transitions, so a
reader can follow a run's shape from `stream.jsonl`, which was impossible
before. Measured on B6, the largest gap between consecutive stream events
is still **1456 s — 24m16s**, the whole agent working phase. So the stream
says which phase a task is in and never whether it is progressing, and
"wedged inside `running`" is where B4 spent 65 minutes.

The entry carries the number that makes the other half actionable.
Transcript inter-entry gaps across five sessions: healthy work never went
quiet longer than **257 s**, and that worst case is a deliberate cold-start
wait; the hang was **65 minutes**. A threshold in 600–900 s catches B4
inside a quarter hour and fires on none of the four healthy sessions.

Three limits are recorded with it, because the number is worth less
without them: n = 4 sessions on one package; it detects *stopped* rather
than *stuck*, and an agent in a retry loop would defeat it; and in B4 the
agent had legitimately finished, so the alarm is "this session is over"
rather than "this session is broken" — which was the right alarm for all
three of that day's hangs.

Why it is a seam and not another line in `_settle`, checked on the live
hang rather than argued: process alive, CPU ticking ~1 s per 20 s, one
socket ESTABLISHED with txq=0 rxq=0, parked in `ep_poll`, task status
`running` — **every external observable was identical to a healthy run.**

**The brief gains the served model name.** B6's worker registered as
`/data/<user>/…/Qwen3.6-27B` — the filesystem path — because
`--served-model-name` was not passed, so a caller's `"model"` field has to
carry the producing machine's directory layout. B5, which did pass it,
registered `Qwen/Qwen3.6-27B`. That is a host-specific identifier baked
into the part of the kit a reader is most likely to copy, in a run whose
whole subject was not doing that. The kit was honest about it; the brief
now asks for it not to happen.

    pytest agent_sys -- 2075 passed, 3 skipped, 4 xfailed

Signed-off-by: yihou <yihou@amd.com>
…s first caller

`Ssh.run` built its remote command with `" ".join(argv)`. `ssh host <string>`
hands that string to the far side's *shell*, which re-splits it on whitespace
and re-interprets globs, `$`, quotes and `;` -- so `["echo", "a b"]` and
`["echo", "a", "b"]` were the same command over there, and a path with a space
in it became two paths. No error is raised anywhere; the caller's argv boundary
is destroyed in transit.

`shlex.join` for the command, `shlex.quote` for `cwd` in the `cd {cwd} && ...`
prefix -- which has the same hole and is the more dangerous of the two, since a
zone path is generated rather than typed.

**This shipped and was never called.** `Ssh` and `DockerExec` had no
constructor anywhere in the tree, including in the tests, so the defect was
reachable only by reading. It is fixed here because this is the branch that
first constructs an `Ssh`.

`LocalConnection` and `DockerExec` never had the problem -- they pass a real
argv to `subprocess` and there is no shell to re-split it. `test_connection.py`
asserts that too, so a later "consistency" edit routing them through a shell
would reintroduce the defect in two more places rather than in one.

Five tests, none of which connects to anything: `subprocess.run` is captured and
what is asserted is the command that *would* be sent, which is where the defect
was. The load-bearing line is that the two argv forms differ -- the exact
spelling of the quoting is `shlex`'s business, not this module's. Verified by
mutation: restoring `" ".join` fails two of the five.

Measured against a real remote shell as well, since a captured argv is not proof
that the far side agrees. `printf '%s\n'` emits one line per argument, which
`echo` cannot distinguish -- and that is why a read never caught this:

    printf with ['a b']     -> 'a b'
    printf with ['a', 'b']  -> 'a\nb'

The probe that ran it is `scratch/single-real-task-2026-08/r1a_probe_ssh_roundtrip.py`.

`pytest agent_sys`: 2080 passed, 3 skipped, 4 xfailed -- 2075 plus the five.

Signed-off-by: yihou <yihou@amd.com>
A zone tree now reaches a second machine. Measured against `openai-355-n04-33`:
3630 files under `/data/yihou/handoffs/runs/<stamp>/zones/`, every file that
crossed still byte-identical (`rsync --dry-run --itemize-changes` reports no
content change on anything that crossed; an md5 of a real file matches on both
sides), and all three far-side `playground/` directories present and empty.

**Two Protocols, not one with an optional capability.** `docker cp` is not
rsync: it cannot express `--delete` or an exclude. A single Protocol whose third
implementation accepted `delete=True` and raised would be capability negotiation
with the branch hidden one level down -- the matrix the main spec's structural
decisions rule out, because *"a matrix makes every caller branch on what a
backend can do, and those branches are untested in the configuration a site
actually runs"*. So `SyncTransport(Connection, Protocol)` adds `rsync_spec()`,
`Ssh` and `LocalConnection` implement it, and **`DockerExec` does not** -- which
makes "this transport cannot sync" a type-level fact that no call site can
branch on. A test asserts the method is *absent* rather than raising.

The name says `rsync` deliberately. The seam is rsync-shaped -- it returns the
two things an rsync command line needs to address the far side -- and a neutral
name would invite an implementation over something that is not rsync, whose
author would rediscover exactly this distinction.

**The copy's semantics never move into the transport.** `--delete` is the whole
of spec §5.3's "made identical" and `--exclude=playground/**` is criterion 16.
`Connection.push` has neither, so the design's original "route the copy through
`conn.push`" would have dropped both silently with every existing test green.
`sync` keeps the flags and the `--stats` parse; a transport only says how to
reach the other end.

**The conflict pre-pass fails closed.** `conflicts` is `filecmp` over two local
trees and there is no cross-host equivalent yet -- open question 4, still open.
What is not open is what to do meanwhile: `PrepareRefused` exists because rsync
cannot report that both sides changed, so a pre-pass that cannot run is a
refusal and not a pass. Same rule as `locality.py`'s "an oracle whose prefix
cannot be formed is an error, not a silently widened blind spot".

The exception is exact rather than convenient: a far side that does not exist yet
has nothing to conflict with, which is `conflicts`' own rule (`()` when either
side is not a directory) asked over the wire. That is also the ordinary case --
a zone is named per attempt -- so the refusal is triggered deliberately in the
probe and in a test rather than waited for, and both carry the control that a
fresh zone still syncs. Without that control a rule refusing *everything* would
look identical.

Signatures, both sides named (`docs/interfaces.md` §1.1):

- `env_mgr/remote/connection.py` gains `SyncTransport`, `rsync_spec` on `Ssh`
  and `LocalConnection`, and `sync_transport(transport, target)` -- the first
  reader `RemoteMapping.transport` / `.target` have ever had. An unknown
  transport raises at composition, while a human is reading configuration.
- `env_mgr/sync.py`: `sync(..., transports=None)`. `_ends` now returns the
  mapping key as a third element (`tests/env_mgr/test_paths.py` moved with it),
  and both it and `remote_root` come from one new `_match` -- `Context.transports`
  is keyed by the same `local_root`, and a second walk would have given the
  prefix rule two writers.
- `env_mgr/protocols.py` + `.pyi`: `Context.transports`. A **sibling** of
  `mapping`, not a widening of it: `sync.remote_root` owns the walk and is cited
  by `paths.zone_env`, which has no use for a transport. Typed `Any` because
  `remote.connection` imports `protocols`, so naming the class would be a cycle.
  The stub-agreement test caught the missing `.pyi` line, which is that guard
  working.
- `env_mgr/meta.py`: `Meta.weak()`, so `mapping_roots()` and the transports built
  beside it cannot select different mappings.
- `cli/main.py`, `cli/environment.py`: build and pass them. `prepare.py` hands
  them to `sync`.

Empty stays the default and the shipped behaviour: no meta file means no
transports, `LocalConnection`, and a run identical to the one before.

Controls, all run. Two full CLI runs differing in one word of the meta file --
`strong` copies nothing and the far side stays empty; `weak` puts the tree
there. Mutation-checked: removing the refusal fails
`test_an_unreadable_far_side_refuses_rather_than_copying`; dropping the host
prefix fails `test_a_transport_puts_the_rsh_and_the_prefix_on_the_command`.

Probes kept in `scratch/single-real-task-2026-08/`. Wrote only under
`/data/yihou/` on the far side and `/var/tmp/yihou/` here; deleted nothing on
either machine.

`pytest agent_sys`: 2090 passed, 3 skipped, 4 xfailed -- 2080 plus ten.

Signed-off-by: yihou <yihou@amd.com>
`sync` runs `rsync --delete`, and where it points is decided by a meta file
somebody edits -- a value supplied from outside deciding what gets destroyed, on
a machine nobody in the session is watching. That is the shape of the
2026-08-31 accident, and the operator's `rm` hook does not reach it: that hook
intercepts a shell `rm`/`rmdir`/`unlink`/`shred`, while this deletion happens
inside `rsync`, invoked from Python, which never goes near a shell. So the one
enforcement that exists does not cover the one deletion the product performs.

`Meta.deletable_roots` is an allow-list the configuration states explicitly, and
`sync.check_delete_scope` refuses any weak mapping whose `remote_root` is not
under one of them. **Empty is the default and empty refuses.**

An allow-list rather than a deny-list of `/`, `/usr`, `/home`: a deny-list is a
deny-list in allow-list clothing, and the next dangerous root is always the one
nobody thought to add. `_under` compares by path component after `normpath`, so
`/data/yihou2` is not inside `/data/yihou` and `..` cannot climb out of a root a
mapping appears to be under -- all three are tested.

**Refused at composition, not at copy.** `cli/main.py` calls it where the meta
file is read, so a bad configuration fails at start-up naming the offending
root, rather than at the first copy having already been believed. It raises
`PrepareRefused`, which `main` already maps to the PRECONDITION exit code, so no
new handler and no new vocabulary.

Two full runs against `openai-355-n04-33`, differing in one field:

    undeclared  exit 2, refused before anything ran; 0 local zones created;
                far-side run dirs 1 -> 1
    declared    exit 3 (the demo's usual accounting); far-side run dirs 1 -> 2,
                3630 files under the new one

Mutation-checked: deleting the call from `cli/main.py` fails
`test_the_run_path_refuses_a_delete_outside_the_declared_roots`, which is a
wiring test rather than a second test of the function.

**What this is and is not.** `_conflicts_across` already refuses when the
far-side path exists, so today `--delete` can only run against a directory
`sync` itself has just created -- this is defence in depth. Answering open
question 4 will relax that refusal, and at that moment this allow-list becomes
the sole guard. Whoever does that must not remove this with it; the docstring
says so where they will be reading.

Its honest limit: a caller that builds a `Context` by hand -- every test does --
bypasses it. This guards the configuration route, which is where the danger
comes from.

Two earlier tests in `tests/cli/test_remote_mapping.py` began failing when this
landed, because their meta files declare a mapping and no allow-list. That is
the guard working; they now declare one, with a comment saying that is not their
subject.

`pytest agent_sys`: 2096 passed, 3 skipped, 4 xfailed -- 2090 plus six.

Signed-off-by: yihou <yihou@amd.com>
Spec §5.5 says the remote surface reaches an agent as **tool calls**, because
"an agent given a natural-language description of how to sync a directory will
improvise, and the improvisation will be wrong in a way nobody notices".
`env_mgr/remote/tools.py` has defined `env_remote_run` / `_push` / `_pull` since
it was written and **no agent could reach any of them**: `Prepared` had no field
to carry a `ToolDef`, `Assignment` had no field to receive one, and
`claude_sdk._options` emitted no `mcp_servers`. Criterion 18 was built and
reachable by nobody.

**This is bigger than "a field and an adapter", and the design note said it
would be a field and an adapter.** The resize has one cause: becoming the first
caller of `tools()` exposed a defect in it.

    tools.py:51  conn.run(command, cwd=_inside(zone, cwd) if cwd else zone.root)

`Zone.root` is the **local** absolute path, handed to a command running on
another machine. Over ssh that is `cd /var/tmp/yihou/...` on a host where the
mirror lives at `/data/yihou/...`, so every `env_remote_run` would `cd`
somewhere that does not exist. **The one configuration where it appears to work
is a strong mapping, where the two paths are equal by definition** -- which is
the worst way for a defect to hide, because it would have shipped looking
correct. So `tools(conn, zone, remote_root)`, a fourth signature change.

Its containment check could not survive the crossing either. `fs.path.contained`
resolves both sides and calls `resolve_strict(zone)`, which requires the root to
**exist** -- against a remote root it denies everything. `contained_syntactically`
is the weaker check for that case and its weakness is stated where it is
defined: a symlink on the far side defeats it. That is consistent with
`remote/__init__.py` already recording the far side as less confined, not a new
hole, and closing it properly means a `realpath` round trip per call.

**§4.12's family, and this is the fifth this week**: `Oracles`, `image_prefixes`,
`Context.mapping`, `MATERIAL_KEYS`, and now `tools()`'s remote root. Built,
never called, and the missing half invisible from the side that exists -- a tool
surface with no consumer looks exactly like a tool surface.

Signatures, both sides named (§1.1):

- `Prepared.tools` (`protocols.py`, `protocols.pyi`, `prepare.Prepared`) ->
  produced by `prepare`, consumed by `agent/runner.py:752`. **A field and not a
  third `EnvManager` method**, on §4.6's own precedent: `wrap_argv` is on the
  returned value for the same reason, and `test_env_manager_exposes_exactly_these`
  pins the method set at two so a third is a decision.
- `Assignment.tools` (`agent/backend.py`) -> produced by the runner, consumed by
  `claude_sdk._options`. Typed `Any` for the reason `confinement` already is.
- `tools(conn, zone, remote_root)` (`env_mgr/remote/tools.py`).
- `Context.far_roots` -- **where the far side is, for every mapping.** Writing
  `_remote_tools` is what found the need: `ctx.mapping` is weak-only, so a
  *strong* mapping's `remote_root` is not in it and tools resolved against it
  would point at nothing for exactly the configuration R1b uses.
- `sync._match` -> `sync.match`, now that `prepare` needs the same one walk.
- `interfaces.md` §4.6.

**Ruled: `transports` carries every mapping; only `sync` filters by strength.**
Strength answers *must bytes be copied*; a transport answers *can I reach the far
side*. `Meta.weak()` was answering the second with the first, and a strong
mapping is one mount seen by two machines -- often the only one with the GPU on
it. Filtering transports by strength would have made the configuration closest
to the goal the one that cannot reach the machine. The distinction is written
into `meta.py`, because the two have looked like synonyms since that file was
written.

The adapter lives only in `claude_sdk.py`: `env_mgr` may not import the SDK and
`agent/backend.py` is backend-agnostic. Three adaptations, each measured:

- **sync to async** via `asyncio.to_thread`. `conn.run` is a blocking
  `subprocess` and a bring-up runs for minutes; awaiting it inline stalls every
  other SDK message for that whole time.
- **`mcp__<server>__<tool>`** for `allowed_tools`. That spelling appears nowhere
  in the SDK -- it is the CLI's -- so it was measured rather than read
  (`scratch/.../c_probe_sdk_tool_reachable.py`). A wrong name is a tool the model
  cannot call, indistinguishable from the tool not existing.
- **refusals propagate, deliberately uncaught.** The SDK turns a raising handler
  into `isError` with `str(e)`, but that is the SDK speaking for its own layer,
  so it was checked end to end (`c_probe_tool_refusal_visible.py`): the text
  reaches the model verbatim **and the model keeps working afterwards** rather
  than treating the tool as broken. Catch-and-re-wrap would only hide a message
  that already arrives.

Since those messages are now read by a model, `_inside`'s wording changed to
match its new audience, and a refusal about a remote path names the **remote**
root -- naming the local zone would send the agent to inspect a directory on the
wrong machine.

Every new test carries the case that must still fail: a mapped zone gets tools
and an unmapped one gets none; a strong mapping gets tools while nothing is
copied; a legitimate relative `cwd` is accepted while `..` and absolute are
refused; `_options` publishes both gates and publishes nothing when there are no
tools. Mutation-checked: dropping `allowed_tools` fails the wiring test, and
returning `()` from `_remote_tools` fails two.

Four surface guards went red on the new field and were updated, not silenced --
`test_prepared_matches_the_declared_surface`, both `test_doubles_conform` tests
and two doubles. `test_doubles_conform`'s own docstring predicted it: a stand-in
for a closed `NamedTuple` "drifts every time the far side grows".

Not run against a real host yet; that is next, and separately.

`pytest agent_sys`: 2108 passed, 3 skipped, 4 xfailed -- 2096 plus twelve.

Signed-off-by: yihou <yihou@amd.com>
`set(ctx.transports) == set(ctx.mapping)` held with the message *"a transport
under an unmatched key is unusable"*, which reads as a rule and is one only for
a weak-only fixture. R1b ran the counterexample: one `strong` mapping leaves
`ctx.mapping` empty and `ctx.transports` holding an `Ssh` to n04-33, and the
agent still got its remote tools, because `_remote_tools` resolves against
`far_roots`.

Measured, not argued -- run 20260901T061648-3264a6 on the shared /home/yihou
export. The agent reported `smci355-...-n04-33` from `env_remote_run` while its
own shell reported `smc300x-...-a16-19`, and the zone was visible on the far
side with `sync.sync` never called: same inode and mtime on both machines, and
a file written from n04-33 readable here at once.

The assertion is unchanged. Only its message is, and the comment now says which
mappings it is about.

Signed-off-by: yihou <yihou@amd.com>
…eadme's

The package's YAML was already parameterised for a remote run -- SRT_MODEL_PATH,
SRT_IMAGE, SRT_WORK_ROOT -- but its prose was not. The readme asserted *local*
in five places and never mentioned the remote tool surface, so an agent handed
`env_remote_run` would still have brought the model up on its own machine.

That failure is invisible, which is why it is worth a commit. The weights sit on
a shared NFS export readable from both hosts (measured: 61 files, 55,586,117,935
bytes, identical on each), so a local bring-up succeeds, produces a real
completion, and passes every shape check. Only the GPU architecture tells the
two apart -- gfx942 here, gfx950 on the far side.

So the location becomes a fact supplied by env_mgr rather than an assumption
baked into the brief: a `## Where the work happens` section keyed on whether the
three tools are present, the four $SRT_* rows retargeted to "the work host", and
the trap named outright -- weights you can `ls` locally are not evidence you are
on the right machine; check the GPU, not the filesystem.

Conditional throughout, so a local run reads exactly as before: absent the
tools, the work host is this machine. The agent is also asked to record which of
the two it was in, so the kit says so rather than leaving a reader to infer it.

Measured, run 20260901T080901-50ecb9: Qwen3.6-27B up in mix mode on
openai-355-n04-33 through env_remote_run, no ssh in any Bash call, containers
present there and absent here across a two-sided poll, kit delivered, both
validators PASS.

Suite unchanged at 2108 passed, 3 skipped, 4 xfailed.

Signed-off-by: yihou <yihou@amd.com>
…e the far side

**This adds a required member to the `Connection` Protocol.** That is a
cross-module signature change under interfaces.md §1.1 whatever the member
does, and it is named here rather than in the body because a seam change that
reads as a cosmetic one is how the next person deletes it. `tools()`'s own
signature is untouched, which is a different and lesser statement.

The three tool descriptions said "the remote side of this task's mapping" and
named no host -- so a reader of the tool could not tell which machine it was
talking to. Measured in run 20260901T080901-50ecb9: the agent's first act was
env_remote_run(["hostname","-f"]), because the surface withheld something it
knew. env_mgr owns that fact and nobody else can supply it.

Two alternatives were rejected:
- pass the host into `tools()` -- pushes a fact the connection owns onto every
  caller, which would then have to learn it from somewhere.
- `isinstance` on the concrete type -- capability negotiation, which the spec
  forbids and which `SyncTransport` was split out to avoid.
- `getattr(conn, "host", ...)` -- works for today's three classes; a fourth
  transport lands in the fallback and is described to the agent as *this
  machine* while executing elsewhere. interfaces.md §4.11's family.

**Scope: identity, not intent.** `Ssh.describe()` ended "...and it is where this
task's work is meant to happen" for one revision. That was wrong: where a task's
work belongs is the task's decision. env_mgr cannot know whether a package wants
a remote resource while working locally, and a claim made here is one the
package cannot contradict. The clause is gone; the task-side half stays in the
package readme, keyed on whether the tools are present.

The locative sentence lives in `describe()` and not in `tools`, and that too was
measured. As a fixed clause it was true over Ssh and self-contradictory over
LocalConnection, whose far side is this host; asked to read it, a model said so
unprompted -- "这与「远端」的措辞本身就相互矛盾".

**Conformance**: a Protocol member is not enforced at runtime, so a double that
omits `describe` stays green until something calls it -- the "wired but reached
by nobody" shape, six occurrences here. tests/agent/test_doubles_conform.py does
not cover `Connection`, so the check is added in tests/env_mgr/test_connection.py
instead, where the type lives; it imports that file's already-tested checkers
rather than copying them, reads the member set out of tools.py by AST rather
than restating it, and carries a control that must still fail.

Evidence, both arms:
- p3_description_reaches_the_model.py: asked to name the machine WITHOUT
  calling anything, the model answers openai-355-n04-33 and the remote zone
  root from the description alone, 0 tool calls. Control: over LocalConnection
  it names no host and says the two ends are one host.
- run 20260901T090540-302dec, weak mapping over two unshared filesystems: the
  agent cross-checked the far side against "the host named in the tool
  description", in a package whose readme names no host.

Two structural test doubles grow the method; that cost is the point of
declaring it. Suite 2108 -> 2111 passed, 3 skipped, 4 xfailed.

Signed-off-by: yihou <yihou@amd.com>
`knowledge/general/working-on-a-remote-host.md` — written for an AI agent
executing a task, distilled from the remote-mode stage and the failures
collected on the way. Desensitised: no hostnames, no site paths, no product
names, no credentials.

What it covers, in the order an agent needs it: how to tell whether the work
host is the machine your shell is on (look at the tool list first, and a path
you can `ls` locally proves nothing on a shared mount); executing through the
tool rather than reaching for `ssh` when stuck; long operations, which need
launching detached and polling because a blocking call tells you nothing until
it is over; local paths not being remote paths, and how shared-filesystem and
copying configurations differ; being a guest on a host other people use; and
evidence, most of which is perishable.

The longest section is on instruments, because that was the dominant failure:
an instrument answering a different question than the one asked, silently, in
the reassuring direction. Six real instances are listed, plus two rules that
would have caught most of them -- assert that a check *can* find something
before believing it found nothing, and note that agreement between tools
sharing a cache is not corroboration.

ROADMAP 4.1 records why the file is parked rather than placed. Nothing reads
`knowledge/` -- `knowledge` is a handoff kind, and a directory is not a
handoff -- so the corpus reaches an agent only if a package author copies from
it by hand. P0 settles where agent-facing text lives across the four channels
that currently carry it, who owns which fact, and how general knowledge gets
into a run. P0 rather than P1 because it gates how every future task is
briefed and the corpus is small enough today that restructuring is cheap.

Signed-off-by: yihou <yihou@amd.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant