pi-crew 0.10.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
3
4
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
5
|
+
import { loadConfig } from "../../config/config.ts";
|
|
4
6
|
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
7
|
+
import type { PiTeamsConfig } from "../../config/types.ts";
|
|
5
8
|
import { registerChildProcess, unregisterChildProcess } from "../../extension/crew-cleanup.ts";
|
|
6
9
|
import type { WorkerExitStatus } from "../../state/types.ts";
|
|
10
|
+
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
7
11
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
8
12
|
import { redactSecretString } from "../../utils/redaction.ts";
|
|
9
13
|
import { getActiveBrokerIssuer } from "../broker/broker-issuer.ts";
|
|
10
14
|
import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
|
|
15
|
+
import { EventLogTailSource } from "../event-log-tail-source.ts";
|
|
16
|
+
import { bridgeEventFromJsonEvent } from "../event-stream-bridge.ts";
|
|
17
|
+
import { classifyOnExit, getSurfaceRuntimeController, makeTerminalEventProbe } from "../surface/degrade.ts";
|
|
18
|
+
import type { SurfaceGateEnvSnapshot } from "../surface/resolve-surface.ts";
|
|
19
|
+
import type { SurfaceProvider } from "../surface/surface-provider.ts";
|
|
20
|
+
import { prepareSurfaceSpawn, type SurfaceExitInfo, type SurfaceSpawnOutcome, waitForSurfaceExit } from "../surface/surface-spawn.ts";
|
|
11
21
|
import { FINAL_DRAIN_MS, HARD_KILL_MS, POST_EXIT_STDIO_GUARD_MS, RESPONSE_TIMEOUT_MS } from "./child-pi-constants.ts";
|
|
12
22
|
import { clearHardKillTimer, killProcessTree, registerActiveChild, unregisterActiveChild } from "./child-pi-kill.ts";
|
|
13
23
|
import { buildFinalChildPiSpawnOptions, prepareSpawnContext } from "./child-pi-spawn.ts";
|
|
@@ -71,10 +81,41 @@ export function redactStderrExcerpt(stderr: string, maxChars: number): string {
|
|
|
71
81
|
|
|
72
82
|
/** Structured lifecycle event emitted by child-pi for critical transitions. */
|
|
73
83
|
export interface ChildPiLifecycleEvent {
|
|
74
|
-
/**
|
|
75
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Event discriminator. `surface_spawned` / `surface_closed` are emitted by
|
|
86
|
+
* the MuxSurface spawn branch (spec §13.1) — no stdio process exists, so
|
|
87
|
+
* `pid` is absent there and surface fields carry the pane identity.
|
|
88
|
+
*/
|
|
89
|
+
type:
|
|
90
|
+
| "spawned"
|
|
91
|
+
| "spawn_error"
|
|
92
|
+
| "response_timeout"
|
|
93
|
+
| "final_drain"
|
|
94
|
+
| "hard_kill"
|
|
95
|
+
| "exit"
|
|
96
|
+
| "close"
|
|
97
|
+
| "surface_spawned"
|
|
98
|
+
| "surface_closed"
|
|
99
|
+
/** T11 (spec §7): a REAL surface spawn attempt failed → headless fallback
|
|
100
|
+
* + the run-scoped spawn-fail lockout counter (3 consecutive → OFF). */
|
|
101
|
+
| "surface_spawn_failed"
|
|
102
|
+
/** FINDING-3 (report 10tier 2026-08-27): a gate rejected surface BEFORE
|
|
103
|
+
* touching the mux (mode-off/async-run/depth/pane-cap/role-not-visible/
|
|
104
|
+
* no-mux). Emitted ONLY when the user opted into surface
|
|
105
|
+
* (visibleAgents non-empty) — default runs stay silent. */
|
|
106
|
+
| "surface_gate_blocked";
|
|
76
107
|
/** Process ID when available. */
|
|
77
108
|
pid?: number;
|
|
109
|
+
/** Pane id (surface events). */
|
|
110
|
+
paneId?: string;
|
|
111
|
+
/** Surface backend kind (surface events). */
|
|
112
|
+
surfaceKind?: "tmux" | "herdr";
|
|
113
|
+
/** Why the pane ended / degraded (surface_closed): "pane-closed" | "mux-dead" | "detached". */
|
|
114
|
+
paneExitReason?: string;
|
|
115
|
+
/** Which gate rejected surface (surface_gate_blocked): one of the SurfaceGateName values. */
|
|
116
|
+
gate?: string;
|
|
117
|
+
/** Env signals the gate saw (surface_gate_blocked) — bounded snapshot, never the whole env. */
|
|
118
|
+
env?: SurfaceGateEnvSnapshot;
|
|
78
119
|
/** Exit code for exit/close events. */
|
|
79
120
|
exitCode?: number | null;
|
|
80
121
|
/** Error message for error events. */
|
|
@@ -116,6 +157,17 @@ export interface ChildPiRunInput {
|
|
|
116
157
|
onSpawn?: (pid: number) => void;
|
|
117
158
|
/** Structured lifecycle events for durable logging (spawn, crash, timeout, kill, exit). */
|
|
118
159
|
onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
|
|
160
|
+
/** F1 fix (battery 2026-09-10): task-state activity bridge for SURFACE workers.
|
|
161
|
+
* Surface workers have no stdout pipe, so onJsonEvent/onStdoutLine never fire
|
|
162
|
+
* and task.heartbeat/agentProgress starve - the heartbeat-watcher then flags a
|
|
163
|
+
* healthy pane worker dead (~340s) and the stale-reconciler cancels the run
|
|
164
|
+
* (observed live: run team_20260910161234, worker productive 7min in pane
|
|
165
|
+
* w2:p8Z, 0 task.progress events). This callback receives every event tailed
|
|
166
|
+
* from the per-agent recorder log so the CALLER can update task liveness state.
|
|
167
|
+
* Contract: the callback MUST NOT append to the per-agent events file being
|
|
168
|
+
* tailed (watch->append loop - see event-log-tail-source.ts header); task-state
|
|
169
|
+
* persist + run-level task.progress are safe (different files). */
|
|
170
|
+
onSurfaceActivity?: (event: Record<string, unknown>) => void;
|
|
119
171
|
maxDepth?: number;
|
|
120
172
|
finalDrainMs?: number;
|
|
121
173
|
/** F12: early-exit the drain when stdout has been silent for this many ms
|
|
@@ -192,6 +244,28 @@ export interface ChildPiRunInput {
|
|
|
192
244
|
/** Base env for depth computation (defaults to process.env). Advanced —
|
|
193
245
|
* used with depthOverride by the root-side delegate handler. */
|
|
194
246
|
env?: NodeJS.ProcessEnv;
|
|
247
|
+
/**
|
|
248
|
+
* MuxSurface A1 (spec §13.1): opt-in/hint for booting this worker in a
|
|
249
|
+
* multiplexer pane instead of a stdio pipe. Undefined → the branch still
|
|
250
|
+
* consults the discovered project config, which defaults surface OFF
|
|
251
|
+
* (`visibleAgents: []`), so behaviour stays headless unless explicitly
|
|
252
|
+
* configured. Any failure degrades to the normal headless spawn path (§3
|
|
253
|
+
* fail-closed) — NEVER throws.
|
|
254
|
+
*
|
|
255
|
+
* - `providers`: pre-resolved provider instances — dispatch-owned (team-runner
|
|
256
|
+
* T11 passes its resolved provider + livePaneCount) and test injection.
|
|
257
|
+
* Hard gates (async run / host depth) still apply.
|
|
258
|
+
* - `config`: overrides discovered config (tests).
|
|
259
|
+
* - `livePaneCount`: panes already live for this run — `MAX_SURFACE_WORKERS`
|
|
260
|
+
* cap check. Defaults 0 until T11 threads manifest state.
|
|
261
|
+
* - `baseDir`: launch-script dir override (default getPiTempBase()).
|
|
262
|
+
*/
|
|
263
|
+
surface?: {
|
|
264
|
+
providers?: { tmux?: SurfaceProvider; herdr?: SurfaceProvider };
|
|
265
|
+
config?: PiTeamsConfig;
|
|
266
|
+
livePaneCount?: number;
|
|
267
|
+
baseDir?: string;
|
|
268
|
+
};
|
|
195
269
|
}
|
|
196
270
|
|
|
197
271
|
export interface ChildPiRunResult {
|
|
@@ -217,6 +291,26 @@ export interface ChildPiRunResult {
|
|
|
217
291
|
* text) still produce a non-empty result. Consumers should prefer rawFinalText
|
|
218
292
|
* first — this is a last-resort fallback. */
|
|
219
293
|
intermediateFindings?: string;
|
|
294
|
+
/**
|
|
295
|
+
* MuxSurface A1: present ONLY when the worker booted inside a multiplexer
|
|
296
|
+
* pane instead of a stdio pipe (spec §13.1). Downstream (T9 EventLogTailSource,
|
|
297
|
+
* T11 degrade) consumes the pane identity; the empty stdout/stderr is EXPECTED
|
|
298
|
+
* in surface mode — events arrive through the per-agent event log, not a pipe.
|
|
299
|
+
*
|
|
300
|
+
* `degraded` is set when classifyOnExit found NO worker.completed within the
|
|
301
|
+
* 2s window after the pane died (spec §7 D3) — task-runner hands that to
|
|
302
|
+
* finalize as `surfaceLost` instead of reading it as an empty/false result.
|
|
303
|
+
*/
|
|
304
|
+
surface?: {
|
|
305
|
+
kind: "tmux" | "herdr";
|
|
306
|
+
paneId: string;
|
|
307
|
+
scriptPath: string;
|
|
308
|
+
degraded?: {
|
|
309
|
+
cause: "pane-closed" | "mux-dead";
|
|
310
|
+
exitReason: string;
|
|
311
|
+
classifiedAt: string;
|
|
312
|
+
};
|
|
313
|
+
};
|
|
220
314
|
}
|
|
221
315
|
|
|
222
316
|
// Base allowlist of non-provider env vars always passed to child workers.
|
|
@@ -259,6 +353,270 @@ function isFinalAssistantEvent(event: unknown): boolean {
|
|
|
259
353
|
return !content.some((part) => asRecord(part)?.type === "toolCall");
|
|
260
354
|
}
|
|
261
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Discover the project crew config for the surface gate. The discovered config
|
|
358
|
+
* DEFAULTS surface off (`visibleAgents: []`) — headless unless explicitly opted
|
|
359
|
+
* in. Any discovery failure → default config (fail-closed §3), never throws.
|
|
360
|
+
*/
|
|
361
|
+
function safeLoadSurfaceConfig(cwd: string): PiTeamsConfig {
|
|
362
|
+
try {
|
|
363
|
+
return loadConfig(cwd).config;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
logInternalError(
|
|
366
|
+
"child-pi.surface-config",
|
|
367
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
368
|
+
"surface gate uses default (off)",
|
|
369
|
+
);
|
|
370
|
+
return {};
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* MuxSurface A1 spawn branch (spec §13.1). Returns a ChildPiRunResult when the
|
|
376
|
+
* worker was booted INSIDE a multiplexer pane (no stdio process is spawned and
|
|
377
|
+
* completion is awaited through the pane's own lifetime), or null to fall
|
|
378
|
+
* through to the normal stdio spawn.
|
|
379
|
+
*
|
|
380
|
+
* NEVER throws: any preparation failure degrades to null (headless §3).
|
|
381
|
+
*/
|
|
382
|
+
async function trySurfaceBranch(
|
|
383
|
+
input: ChildPiRunInput,
|
|
384
|
+
depthEnv: NodeJS.ProcessEnv | undefined,
|
|
385
|
+
builtArgs: string[],
|
|
386
|
+
mergedEnv: NodeJS.ProcessEnv,
|
|
387
|
+
builtEnv: Record<string, string | undefined>,
|
|
388
|
+
tempDir: string | undefined,
|
|
389
|
+
): Promise<ChildPiRunResult | null> {
|
|
390
|
+
// A pane needs a task id for its title/event-log path — an anonymous worker
|
|
391
|
+
// has nothing meaningful to boot there, so stay on the headless road.
|
|
392
|
+
if (!input.agentId) return null;
|
|
393
|
+
const surfaceOpts = input.surface;
|
|
394
|
+
const preResolved = surfaceOpts?.providers?.tmux ?? surfaceOpts?.providers?.herdr;
|
|
395
|
+
// T11 (spec §7): per-run degrade controller — team-runner owns the policy
|
|
396
|
+
// (lockout counters, pane cap accounting); this layer only consults it.
|
|
397
|
+
// Absent controller (delegate spawn outside a team run, unit tests) keeps
|
|
398
|
+
// today's behavior untouched.
|
|
399
|
+
const taskId = input.agentId;
|
|
400
|
+
const degradeController = getSurfaceRuntimeController(input.runId);
|
|
401
|
+
if (degradeController && !degradeController.shouldAttemptSurface()) return null;
|
|
402
|
+
const surfaceConfig = surfaceOpts?.config ?? safeLoadSurfaceConfig(input.cwd);
|
|
403
|
+
let outcome: SurfaceSpawnOutcome;
|
|
404
|
+
try {
|
|
405
|
+
outcome = await prepareSurfaceSpawn({
|
|
406
|
+
// Detection env must be the HOST's base env (depth 0 tier-1), NOT the
|
|
407
|
+
// child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
|
|
408
|
+
// guard would misfire on our own tier-1 workers.
|
|
409
|
+
env: depthEnv ?? process.env,
|
|
410
|
+
workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model).env as Record<string, string>,
|
|
411
|
+
config: surfaceConfig,
|
|
412
|
+
role: input.role ?? input.agent.name,
|
|
413
|
+
livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
|
|
414
|
+
taskId,
|
|
415
|
+
cwd: input.cwd,
|
|
416
|
+
piArgs: builtArgs,
|
|
417
|
+
stateRoot: input.eventsPath ? path.dirname(input.eventsPath) : "",
|
|
418
|
+
baseDir: surfaceOpts?.baseDir,
|
|
419
|
+
deps: preResolved ? { provider: preResolved } : {},
|
|
420
|
+
});
|
|
421
|
+
} catch (error) {
|
|
422
|
+
// Defense in depth — prepareSurfaceSpawn is no-throw by contract (§3).
|
|
423
|
+
logInternalError("child-pi.surface-unexpected", error instanceof Error ? error : new Error(String(error)), `taskId=${taskId}`);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if (outcome.mode !== "surface") {
|
|
427
|
+
// Spawn-fail ≠ flap (spec §7) but it feeds ITS OWN lockout counter. Only
|
|
428
|
+
// `attempted` outcomes count — gate/resolve-null rejections are the mux
|
|
429
|
+
// simply being absent, not the multiplexer misbehaving.
|
|
430
|
+
if (outcome.mode === "headless" && outcome.attempted) {
|
|
431
|
+
degradeController?.notifySpawnFailed({ taskId, reason: outcome.reason ?? "surface spawn failed" });
|
|
432
|
+
input.onLifecycleEvent?.({
|
|
433
|
+
type: "surface_spawn_failed",
|
|
434
|
+
surfaceKind: preResolved?.kind,
|
|
435
|
+
error: outcome.reason,
|
|
436
|
+
ts: new Date().toISOString(),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
// FINDING-3 (report 10tier 2026-08-27 adjudication): the gate-null path
|
|
440
|
+
// used to be completely silent — a misconfigured surface run (wrong
|
|
441
|
+
// visibleAgents, mode typo, …) was indistinguishable from a by-design
|
|
442
|
+
// headless run in events.jsonl. Emit ONLY when the user opted into
|
|
443
|
+
// surface (visibleAgents non-empty) so default runs gain zero noise.
|
|
444
|
+
// The lockout path above stays silent here — its cause already emitted
|
|
445
|
+
// surface.degraded events on the way to locking.
|
|
446
|
+
if (outcome.mode === "headless" && outcome.gateRejected) {
|
|
447
|
+
const visibleAgents = surfaceConfig.runtime?.surface?.visibleAgents ?? [];
|
|
448
|
+
if (visibleAgents.length > 0) {
|
|
449
|
+
input.onLifecycleEvent?.({
|
|
450
|
+
type: "surface_gate_blocked",
|
|
451
|
+
gate: outcome.gateRejected.gate,
|
|
452
|
+
error: outcome.gateRejected.reason,
|
|
453
|
+
env: outcome.gateRejected.env,
|
|
454
|
+
ts: new Date().toISOString(),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const surfaceMeta: NonNullable<ChildPiRunResult["surface"]> = {
|
|
462
|
+
kind: outcome.kind,
|
|
463
|
+
paneId: outcome.paneId,
|
|
464
|
+
scriptPath: outcome.scriptPath,
|
|
465
|
+
};
|
|
466
|
+
degradeController?.notifySpawned({
|
|
467
|
+
taskId,
|
|
468
|
+
paneId: outcome.paneId,
|
|
469
|
+
provider: outcome.kind,
|
|
470
|
+
// Task 5 (tab-layout): tab của run lên controller → manifest surface.tabs
|
|
471
|
+
// — run end (closeRunTabs) đóng đúng tab, worker xong KHÔNG đóng.
|
|
472
|
+
...(outcome.tabKey && outcome.tabId ? { tabKey: outcome.tabKey, tabId: outcome.tabId } : {}),
|
|
473
|
+
});
|
|
474
|
+
input.onLifecycleEvent?.({
|
|
475
|
+
type: "surface_spawned",
|
|
476
|
+
surfaceKind: outcome.kind,
|
|
477
|
+
paneId: outcome.paneId,
|
|
478
|
+
ts: new Date().toISOString(),
|
|
479
|
+
});
|
|
480
|
+
// T9 (spec §5.3): surface worker không có stdout JSON stream — worker-side
|
|
481
|
+
// recorder ghi per-agent events.jsonl; host tail file đó và bridge thẳng ra
|
|
482
|
+
// run event bus cho dashboard/sidebar ("như cũ"). KHÔNG đưa vào
|
|
483
|
+
// input.onJsonEvent: callback task-runner gọi appendCrewAgentEventBuffered
|
|
484
|
+
// vào CHÍNH file này (worker đã ghi sẵn) → ghi đôi + vòng tự kích hoạt
|
|
485
|
+
// watch→append→watch không dừng (xem header event-log-tail-source.ts).
|
|
486
|
+
const eventSource = outcome.eventsPath ? new EventLogTailSource({ eventsPath: outcome.eventsPath }) : undefined;
|
|
487
|
+
if (eventSource && input.runId && input.agentId) {
|
|
488
|
+
const runId = input.runId;
|
|
489
|
+
const taskId = input.agentId;
|
|
490
|
+
const controllerForBridge = degradeController;
|
|
491
|
+
eventSource.onEvent((event) => {
|
|
492
|
+
// T11: the worker's own `worker.started` self-report carries the pid +
|
|
493
|
+
// session path that doctor/degrade-resume need (§12.2). Bridge it into
|
|
494
|
+
// the run-scoped controller BEFORE the dashboard shim — it is the only
|
|
495
|
+
// host-side consumer of that payload.
|
|
496
|
+
if ((event as { type?: unknown } | undefined)?.type === "worker.started") {
|
|
497
|
+
const started = event as { pid?: unknown; sessionPath?: unknown };
|
|
498
|
+
controllerForBridge?.notifyWorkerStarted({
|
|
499
|
+
taskId,
|
|
500
|
+
pid: typeof started.pid === "number" ? started.pid : undefined,
|
|
501
|
+
sessionPath: typeof started.sessionPath === "string" ? started.sessionPath : undefined,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const bridgeEvent = bridgeEventFromJsonEvent(runId, taskId, event);
|
|
505
|
+
if (bridgeEvent) runEventBus.emit({ type: "worker_status", runId, taskId, data: bridgeEvent });
|
|
506
|
+
// F1 fix (battery 2026-09-10): feed the same tailed event to the task-state
|
|
507
|
+
// bridge so child-executor can keep task.heartbeat/agentProgress fresh for
|
|
508
|
+
// surface workers (see ChildPiRunInput.onSurfaceActivity doc).
|
|
509
|
+
input.onSurfaceActivity?.(event as Record<string, unknown>);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
let exitInfo: SurfaceExitInfo;
|
|
513
|
+
try {
|
|
514
|
+
// Pane lifetime IS the worker lifetime in A1: T8's auto-exit closes the
|
|
515
|
+
// pane at turn end (spec §13.2); cancel closes it here via AbortSignal and
|
|
516
|
+
// the response deadline force-closes a WEDGED worker (fix round 1/F2 —
|
|
517
|
+
// parity with headless minimum timeout so a stuck pane cannot hold its
|
|
518
|
+
// worker slot forever; A1 has no stream-activity signal so this is ONE
|
|
519
|
+
// hard deadline).
|
|
520
|
+
exitInfo = await waitForSurfaceExit(outcome, {
|
|
521
|
+
signal: input.signal,
|
|
522
|
+
deadlineMs: input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS,
|
|
523
|
+
});
|
|
524
|
+
} finally {
|
|
525
|
+
eventSource?.close();
|
|
526
|
+
}
|
|
527
|
+
// T11 classify (spec §7): D7 report-before-dying means a normally-finishing
|
|
528
|
+
// worker already wrote `worker.completed` to the RUN event log before its
|
|
529
|
+
// pane closed, so the probe finds it on the first scan. Nothing within the
|
|
530
|
+
// 2s window → the worker never finished → the pane death is a DEGRADE and
|
|
531
|
+
// team-runner re-dispatches this unit headless. Cancel/deadline force-closes
|
|
532
|
+
// are HOST-initiated — classify is skipped there (never counts as mux flap),
|
|
533
|
+
// but the pane release itself is reported in EVERY case so the run's live
|
|
534
|
+
// pane cap stays accurate.
|
|
535
|
+
let completedPayload: Record<string, unknown> | undefined;
|
|
536
|
+
if (!exitInfo.cancelledByAbort && !exitInfo.timedOut) {
|
|
537
|
+
const probe = makeTerminalEventProbe({ eventsPath: input.eventsPath ?? "", taskId, runId: input.runId ?? undefined });
|
|
538
|
+
const verdict = await classifyOnExit(outcome.handle, probe);
|
|
539
|
+
const completed = verdict === "completed";
|
|
540
|
+
if (completed) {
|
|
541
|
+
const payload = probe.foundPayload();
|
|
542
|
+
completedPayload = payload && typeof payload.result === "string" && payload.result.trim().length > 0 ? payload : undefined;
|
|
543
|
+
} else {
|
|
544
|
+
surfaceMeta.degraded = {
|
|
545
|
+
cause: exitInfo.reason === "mux-dead" ? "mux-dead" : "pane-closed",
|
|
546
|
+
exitReason: exitInfo.reason,
|
|
547
|
+
classifiedAt: new Date().toISOString(),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
degradeController?.notifyPaneExited({
|
|
551
|
+
taskId,
|
|
552
|
+
paneId: outcome.paneId,
|
|
553
|
+
completed,
|
|
554
|
+
exitReason: exitInfo.reason,
|
|
555
|
+
});
|
|
556
|
+
} else {
|
|
557
|
+
degradeController?.notifyPaneExited({
|
|
558
|
+
taskId,
|
|
559
|
+
paneId: outcome.paneId,
|
|
560
|
+
completed: false,
|
|
561
|
+
exitReason: exitInfo.reason,
|
|
562
|
+
cancelledByAbort: exitInfo.cancelledByAbort,
|
|
563
|
+
timedOut: exitInfo.timedOut,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Result text captured from `worker.completed` — WITHOUT it every surface
|
|
567
|
+
// task would look empty downstream (rawFinalText/stdout are the only result
|
|
568
|
+
// channels post-execution trusts) and flip to failed via the empty-output
|
|
569
|
+
// guard even though the worker SUCCEEDED.
|
|
570
|
+
const surfaceResultText = typeof completedPayload?.result === "string" ? completedPayload.result : "";
|
|
571
|
+
input.onLifecycleEvent?.({
|
|
572
|
+
type: "surface_closed",
|
|
573
|
+
surfaceKind: outcome.kind,
|
|
574
|
+
paneId: outcome.paneId,
|
|
575
|
+
paneExitReason: exitInfo.reason,
|
|
576
|
+
ts: new Date().toISOString(),
|
|
577
|
+
});
|
|
578
|
+
// F1: the pane is gone — nothing can still need the prompt/task temp dir.
|
|
579
|
+
// The headless branch cleans this up in settle(); surface returns early.
|
|
580
|
+
cleanupTempDir(tempDir);
|
|
581
|
+
// Empty stdout/stderr is EXPECTED — events flow through the per-agent event
|
|
582
|
+
// log (worker-side recorder, spec §5.3), not through a pipe we own. The only
|
|
583
|
+
// text the worker produced surfaces via `worker.completed` (surfaceResultText).
|
|
584
|
+
return {
|
|
585
|
+
exitCode: exitInfo.cancelledByAbort || exitInfo.timedOut ? null : 0,
|
|
586
|
+
stdout: "",
|
|
587
|
+
stderr: "",
|
|
588
|
+
...(exitInfo.cancelledByAbort
|
|
589
|
+
? { error: `Cancelled while running in ${outcome.kind} pane ${outcome.paneId} (${exitInfo.reason})` }
|
|
590
|
+
: {}),
|
|
591
|
+
...(exitInfo.timedOut
|
|
592
|
+
? {
|
|
593
|
+
error:
|
|
594
|
+
`Surface worker in ${outcome.kind} pane ${outcome.paneId} produced no completion within ` +
|
|
595
|
+
`${input.responseTimeoutMs ?? RESPONSE_TIMEOUT_MS}ms response timeout; pane was force-closed.`,
|
|
596
|
+
}
|
|
597
|
+
: {}),
|
|
598
|
+
rawFinalText: surfaceResultText,
|
|
599
|
+
intermediateFindings: "",
|
|
600
|
+
...(exitInfo.cancelledByAbort ? { aborted: true } : {}),
|
|
601
|
+
surface: surfaceMeta,
|
|
602
|
+
exitStatus: {
|
|
603
|
+
exitCode: exitInfo.cancelledByAbort || exitInfo.timedOut ? null : 0,
|
|
604
|
+
cancelled: exitInfo.cancelledByAbort,
|
|
605
|
+
timedOut: exitInfo.timedOut,
|
|
606
|
+
killed: false,
|
|
607
|
+
cleanupErrors: [],
|
|
608
|
+
finalDrainMs: 0,
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Test seam — trySurfaceBranch là internal wiring giữa prepareSurfaceSpawn và
|
|
615
|
+
* lifecycle events (surface_gate_blocked). Mock mode intercepts runChildPi
|
|
616
|
+
* BEFORE this branch, so unit tests exercise the wiring through this export.
|
|
617
|
+
*/
|
|
618
|
+
export const __test__trySurfaceBranch = trySurfaceBranch;
|
|
619
|
+
|
|
262
620
|
export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
|
|
263
621
|
// Phase 1 (live-session parity): prepend parent context when inheritContext is true.
|
|
264
622
|
// This mirrors the effectivePrompt logic in live-session-runtime.ts so that
|
|
@@ -304,8 +662,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
304
662
|
if (!brokerSpawn && brokerIssuer && input.runId) {
|
|
305
663
|
try {
|
|
306
664
|
// ADR-5 §4: thread the child depth so the issuer can contain broker
|
|
307
|
-
// credentials at depths that may not delegate (
|
|
308
|
-
// maxDepth=
|
|
665
|
+
// credentials at depths that may not delegate (a child at the default
|
|
666
|
+
// maxDepth=4 cap gets NO socket/token — env containment AC).
|
|
309
667
|
brokerSpawn = await brokerIssuer(input.runId, input.agentId, input.depthOverride);
|
|
310
668
|
} catch (error) {
|
|
311
669
|
// H8 (2026-08-10): surface the silent degradation. Previously this
|
|
@@ -325,7 +683,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
325
683
|
}
|
|
326
684
|
const spawnPrep = prepareSpawnContext(brokerSpawn ? { ...input, brokerSpawn } : input, effectiveTask, depthEnv);
|
|
327
685
|
if (spawnPrep.kind === "aborted") return spawnPrep.result;
|
|
328
|
-
const { spawnSpec, mergedEnv, tempDir, builtEnv } = spawnPrep.ctx;
|
|
686
|
+
const { spawnSpec, mergedEnv, tempDir, builtEnv, builtArgs } = spawnPrep.ctx;
|
|
687
|
+
// MuxSurface A1 (spec §13.1): attempt booting the worker in a mux pane BEFORE
|
|
688
|
+
// spawning a stdio pipe. Returns a finished result in surface mode (pane exit
|
|
689
|
+
// awaited) or null → fall through to the classic headless spawn below.
|
|
690
|
+
const surfaceResult = await trySurfaceBranch(input, depthEnv, builtArgs, mergedEnv, builtEnv, tempDir);
|
|
691
|
+
if (surfaceResult) return surfaceResult;
|
|
329
692
|
try {
|
|
330
693
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
331
694
|
// Compose the final SpawnOptions: canary + filter + spread are now
|
|
@@ -98,7 +98,19 @@ export function agentStatusPath(manifest: TeamRunManifest, taskId: string): stri
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export function agentEventsPath(manifest: TeamRunManifest, taskId: string): string {
|
|
101
|
-
return
|
|
101
|
+
return agentEventsPathForStateRoot(manifest.stateRoot, taskId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Per-agent events log dưới stateRoot bất kỳ (không cần manifest đầy đủ).
|
|
106
|
+
* MỘT công thức cho mọi bên: host writers/readers (qua agentEventsPath) và
|
|
107
|
+
* surface spawn (S2-T7 set PI_CREW_AGENT_EVENTS_PATH, S2-T9 tail) — tất cả
|
|
108
|
+
* qua safeAgentTaskId, nên taskId chứa ":" (delegate-spawn ids) rơi về CÙNG
|
|
109
|
+
* file cho cả worker recorder lẫn agent-view. Throw khi phần id sau sanitize
|
|
110
|
+
* không hợp lệ (assertSafePathId) — caller fail-closed tự xử lý.
|
|
111
|
+
*/
|
|
112
|
+
export function agentEventsPathForStateRoot(stateRoot: string, taskId: string): string {
|
|
113
|
+
return path.join(stateRoot, "agents", safeAgentTaskId(taskId), "events.jsonl");
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
export function agentOutputPath(manifest: TeamRunManifest, taskId: string): string {
|
|
@@ -118,7 +118,16 @@ export async function sweepExpiredWaitingTasks(
|
|
|
118
118
|
cwd: string,
|
|
119
119
|
runId: string,
|
|
120
120
|
now = Date.now(),
|
|
121
|
+
// PERF (2026-08-24): callers in the scheduler loop already hold a task view
|
|
122
|
+
// loaded this tick. Check the cheap expiry predicate against it BEFORE
|
|
123
|
+
// paying stat+parse of manifest.json + tasks.json — the sweep fires on every
|
|
124
|
+
// unit settle/dispatch and almost always finds nothing expired.
|
|
125
|
+
hintTasks?: TeamTaskState[],
|
|
121
126
|
): Promise<WaitingDeadlineSweepResult | undefined> {
|
|
127
|
+
if (hintTasks) {
|
|
128
|
+
const hintExpired = hintTasks.some((t) => t.status === "waiting" && t.waiting !== undefined && t.waiting.deadline <= now);
|
|
129
|
+
if (!hintExpired) return undefined;
|
|
130
|
+
}
|
|
122
131
|
const initial = loadRunManifestById(cwd, runId);
|
|
123
132
|
if (!initial) return undefined;
|
|
124
133
|
const hasExpired = initial.tasks.some((t) => t.status === "waiting" && t.waiting !== undefined && t.waiting.deadline <= now);
|
|
@@ -375,7 +384,9 @@ function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): str
|
|
|
375
384
|
* Mutates ctx.manifest/ctx.tasks from the sweeps' disk-reloaded results.
|
|
376
385
|
*/
|
|
377
386
|
export async function runSchedulerSweeps(ctx: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): Promise<void> {
|
|
378
|
-
|
|
387
|
+
// PERF (2026-08-24): pass this tick's task view as the expiry hint — the
|
|
388
|
+
// sweep then skips the manifest load entirely when nothing is expired.
|
|
389
|
+
const sweep = await sweepExpiredWaitingTasks(ctx.manifest.cwd, ctx.manifest.runId, Date.now(), ctx.tasks);
|
|
379
390
|
if (sweep) {
|
|
380
391
|
ctx.manifest = sweep.manifest;
|
|
381
392
|
ctx.tasks = sweep.tasks;
|