Skip to content

Commit 0135406

Browse files
committed
Keep wait_agents live and strip leftover fleet ads
The fused task wrapper is gone, so wait_agents is a long-lived parent tool and spawn_agent progress lasts the worker lifetime. Exec overlay tests now pin that mountFleet:false drops fleet verbs, not merely the old task name.
1 parent 8a362b5 commit 0135406

25 files changed

Lines changed: 346 additions & 101 deletions

evals/capability/README.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -183,21 +183,21 @@ change that justifies it.
183183

184184
Flags:
185185

186-
| Flag | Meaning |
187-
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
188-
| `--case <id\|all>` | Case id or `all` (default) |
189-
| `--provider <name>` / `--model <id>` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings |
190-
| `--matrix <cells>` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides |
191-
| `--config <path>` | Settings file override (CI injection) |
192-
| `--out <path>` | Write machine-readable results JSON |
193-
| `--baseline <path>` | Compare this run to a prior results file (improve/regress + metric deltas) |
194-
| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` |
195-
| `--agent-timeout-ms <n>` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) |
196-
| `--verify-timeout-ms <n>` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) |
197-
| `--repeats <n>` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates |
198-
| `--concurrency <n>` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster |
199-
| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` |
200-
| `--director <id>` | Exec overlay: run the product `corbits exec` path with this director's system prompt and initially-advertised tool set (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `build`) do not mount `task`. |
186+
| Flag | Meaning |
187+
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
188+
| `--case <id\|all>` | Case id or `all` (default) |
189+
| `--provider <name>` / `--model <id>` | Required unless `--matrix`. Single-variant via `loadConfig`. Not inferred from local settings |
190+
| `--matrix <cells>` | Alternative to `--provider`/`--model`. Multi-variant: `p:m,p2:m2` or `label=p:m` (comma-separated). Every cell must include both sides |
191+
| `--config <path>` | Settings file override (CI injection) |
192+
| `--out <path>` | Write machine-readable results JSON |
193+
| `--baseline <path>` | Compare this run to a prior results file (improve/regress + metric deltas) |
194+
| `--ask-permissions` | Do **not** pass `--dangerously-skip-permissions` |
195+
| `--agent-timeout-ms <n>` | Wall-clock limit for `runExec` (default `1200000`, env `CORBITS_EVAL_AGENT_TIMEOUT_MS`) |
196+
| `--verify-timeout-ms <n>` | Wall-clock limit for `verify.sh` (default `120000`, env `CORBITS_EVAL_VERIFY_TIMEOUT_MS`) |
197+
| `--repeats <n>` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates |
198+
| `--concurrency <n>` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster |
199+
| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` |
200+
| `--director <id>` | Exec overlay: run the product `corbits exec` path with this director's system prompt and initially-advertised tool set (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `build`) do not mount `spawn_agent` / `wait_agents`. |
201201

202202
## Case format
203203

plugins/corbits-skills/skills/opsh/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ description: Write scripts using opsh and its built-in libraries. Tiny scripts:
66

77
# opsh Scripting
88

9-
How to write scripts with opsh and its libraries. Tiny / single-file scripts: DIY with write_file/edit_file using these rules. Substantial script work: spawn `spawn_agent(agent="builder")` with these rules copied into the brief (workers do not mount `use_skill`). For a review, spawn `spawn_agent(agent="critic")` (or `spawn_agent(agent="neckbeard")` for hygiene-only) with the same rules copied in.
9+
How to write scripts with opsh and its libraries. Tiny / single-file scripts: DIY with write_file/edit_file using these rules. Substantial script work: spawn `spawn_agent(agent="builder")` with these rules copied into the brief, then collect with `wait_agents` (workers do not mount `use_skill`). For a review, spawn `spawn_agent(agent="critic")` (or `spawn_agent(agent="neckbeard")` for hygiene-only) with the same rules copied in, then `wait_agents`.
1010

1111
Shell for agent commands is `run_shell`. Bash-the-language in the examples below stays — opsh scripts are bash.
1212

src/agent/codex-tool-proxies.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ function createUpdatePlanProxy(runManageTasks: CodexRunManageTasks): AgentTool {
438438
// (pending/in_progress/completed) — this proxy never produces it, so a
439439
// Codex model cannot cancel a step through update_plan. That is a
440440
// lossy-but-safe narrowing (dropped, not misrepresented), not a bug fix
441-
// for the underlying task tool, which stays out of scope here.
441+
// for the underlying manage_tasks tool, which stays out of scope here.
442442
const tasks = parsed.plan.map((item, i) => ({
443443
id: `p${i + 1}`,
444444
title: item.step,

src/agent/profile-types.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,9 @@ export interface AgentProfile {
6262
// plugin-contributed profiles, or .agents/agents/ for local profiles. When both
6363
// systemPromptRole and systemPromptPath are set, systemPromptRole wins.
6464
systemPromptPath?: string;
65-
// Orchestrator agents are an explicit exception to the "sub-agents do not
66-
// recurse" rule. When true, the dispatch-time appendix grants this profile
67-
// permission to call `task` to spawn other agents. Reserved for top-level
68-
// coordinators (e.g. a planning agent that fans work out to specialists);
69-
// leaf-task agents should leave this unset.
65+
// Reserved for future profile-sourced orchestrator support. Today spawn_agent
66+
// rejects profile orchestrators because only built-in director packages carry
67+
// trusted fleet semantics. Leaf workers should leave this unset.
7068
orchestrator?: boolean;
7169
// Where the profile came from, for search_agents labeling (e.g. "claude",
7270
// "plugin:<id>", "local"). Omitted for built-in defaults.

src/provider/reasoning-effort.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export interface ResolveEffortForRoleOpts {
240240
*
241241
* Pins are still highest precedence, but an unsupported pin is clamped so the
242242
* pure API owns the "never emit an unsupported effort" invariant (callers that
243-
* want hard-fail on bad pins should validateEffort first, as task-tool does).
243+
* want hard-fail on bad pins should validateEffort first, as spawn_agent does).
244244
*/
245245
export function pickEffortFromCascade(opts: {
246246
pin?: ReasoningEffort;

src/subagent/agent-fleet.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, expect, test } from "bun:test";
2+
import { mkdtemp, readFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
25

36
import {
47
createFleetRecords,
@@ -19,6 +22,7 @@ import { agentLaneIsLive, fleetProgress } from "../tui/agent-progress.js";
1922
import { AGENTS_PANEL_LINGER_MS, formatAgentsPanel } from "../tui/chrome-state.js";
2023
import { forcedStopReport } from "./stop-policy.js";
2124
import type { RunSubAgentParams, RunSubAgentResult } from "./types.js";
25+
import { INTERVENTION_FILE } from "./intervention-log.js";
2226

2327
const testPermissionGate = createPermissionGate({
2428
approvals: [],
@@ -384,6 +388,46 @@ describe("spawn_agent same-cwd concurrency", () => {
384388
gates[0]!.resolve({ report: "one done" });
385389
gates[1]!.resolve({ report: "two done" });
386390
});
391+
392+
test("two concurrent shared-cwd spawn_agent lanes log concurrent-lane-overlap", async () => {
393+
const dir = await mkdtemp(join(tmpdir(), "fleet-overlap-"));
394+
const gates = [deferred<RunSubAgentResult>(), deferred<RunSubAgentResult>()];
395+
let callIndex = 0;
396+
const deps = makeDeps(async () => gates[callIndex++]!.promise, { cwd: "/repo" });
397+
deps.getWorkdirBase = () => dir;
398+
const spawn = createSpawnAgentTool(deps);
399+
400+
await callTool(spawn, {
401+
description: "build one",
402+
prompt: "implement thing one",
403+
intent: "implement",
404+
});
405+
await callTool(spawn, {
406+
description: "build two",
407+
prompt: "implement thing two",
408+
intent: "implement",
409+
});
410+
411+
const path = join(dir, INTERVENTION_FILE);
412+
let log = "";
413+
for (let i = 0; i < 50; i++) {
414+
try {
415+
log = await readFile(path, "utf8");
416+
if (log.includes("concurrent-lane-overlap")) break;
417+
} catch {
418+
// append is fire-and-forget
419+
}
420+
await new Promise((resolve) => setTimeout(resolve, 20));
421+
}
422+
expect(log).toContain("concurrent-lane-overlap");
423+
expect(log).toContain("conflict");
424+
expect(log).toContain("/repo");
425+
expect(log).toContain("build one");
426+
expect(log).toContain("build two");
427+
428+
gates[0]!.resolve({ report: "one done" });
429+
gates[1]!.resolve({ report: "two done" });
430+
});
387431
});
388432

389433
describe("fleetRecords retention cap", () => {

src/subagent/agent-fleet.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787

8888
import { formatSubAgentSpawnAuthFailureMessage } from "./inference-auth-failure.js";
8989
import { isSubAgentCancelError } from "./dispose.js";
90+
import { createInterventionLog, type InterventionSink } from "./intervention-log.js";
9091

9192
const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "agent-fleet"]);
9293

@@ -592,6 +593,23 @@ function resolveAgentDispatch(input: {
592593

593594
export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
594595
const telemetry = deps.telemetry ?? NOOP_TELEMETRY;
596+
// Concurrent-lane overlap detection, replacing the static per-package
597+
// writePaths lock. There is no field in the spawn_agent contract a caller
598+
// uses to declare which files a dispatch will touch, so the only honestly
599+
// knowable "intended scope" at spawn is the working directory the dispatch
600+
// will run in — worktree-isolated lanes always get a fresh, disjoint path
601+
// here, so this can only ever fire in the shared-cwd fallback, which is
602+
// exactly where two lanes really can stomp each other's writes.
603+
// Keyed by call.id so a completed lane (removed when the worker settles)
604+
// is never mistaken for one still running: sequential dispatches to the
605+
// same cwd are always clean. Tracking lasts the worker lifetime, not the
606+
// immediate spawn_agent return.
607+
const activeLanes = new Map<string, { description: string; cwd: string }>();
608+
let conflictLog: InterventionSink | null = null;
609+
const recordConflict = (event: Parameters<InterventionSink>[0]): void => {
610+
conflictLog ??= createInterventionLog(deps.getWorkdirBase(), { role: "parent" });
611+
conflictLog(event);
612+
};
595613
return tool({
596614
definition: spawnAgentToolDefinition,
597615
handler: async (call, _signal): Promise<ToolResult> => {
@@ -845,6 +863,27 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
845863
}
846864
};
847865

866+
// Detect, don't lock: warn when another lane still running right now
867+
// is already working in this same cwd. Worktree-isolated lanes never
868+
// collide here (each gets its own directory); this only fires in the
869+
// shared-cwd fallback, where two lanes genuinely can overwrite each
870+
// other's writes. Never blocks the spawn — the least destructive
871+
// response that still tells the operator something true, since a
872+
// shared cwd does not by itself prove the two lanes touch the same
873+
// files, only that they could.
874+
const laneCwd = worktreeCwd ?? deps.cwd;
875+
for (const [otherId, other] of activeLanes) {
876+
if (other.cwd !== laneCwd) continue;
877+
recordConflict({
878+
id: "concurrent-lane-overlap",
879+
class: "conflict",
880+
detail:
881+
`"${description}" (${call.id}) and "${other.description}" (${otherId}) ` +
882+
`are both running against ${laneCwd} at once`,
883+
});
884+
}
885+
activeLanes.set(call.id, { description, cwd: laneCwd });
886+
848887
const params: RunSubAgentParams = {
849888
// Name the trace directory after the session-store id so the
850889
// descendant-scoping check behind read_agent_trace can resolve this
@@ -978,6 +1017,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool {
9781017
deps.sessions.fail(session.id, failReason);
9791018
})
9801019
.finally(() => {
1020+
activeLanes.delete(call.id);
9811021
finalizeEnd();
9821022
if (!keepWorktreeAlive) void reclaimWorktree();
9831023
});

src/telemetry/feedback.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function getLastTurnTraceId(): string | undefined {
173173

174174
/**
175175
* Remember the in-flight turn's `$ai_trace_id` so `subagent_end` can link to the
176-
* turn that is still running when `task` / `spawn_agent` dispatch (not the
176+
* turn that is still running when `spawn_agent` dispatch (not the
177177
* previous completed turn).
178178
*/
179179
export function noteCurrentTurnTraceId(traceId: string): void {

src/tui/agent-progress.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe("agentProgress", () => {
153153

154154
test("default stall window tolerates a multi-minute Grok think gap", () => {
155155
// DEFAULT_STALL_MS is 300s — 180s of quiet with no tool outstanding must
156-
// still read working, or Task rows false-stall on healthy Responses thinks.
156+
// still read working, or worker/spawn_agent rows false-stall on healthy Responses thinks.
157157
const progress = agentProgress({ ...base, currentToolName: null, lastActivityAt: 0 }, 180_000);
158158
expect(progress?.state).toBe("working");
159159
expect(progress?.stalled).toBe(false);

0 commit comments

Comments
 (0)