pi-crew 0.11.1 → 0.11.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/CHANGELOG.md +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -28,7 +28,7 @@ import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
|
28
28
|
import { appendMailboxMessageAsync, type MailboxMessage, registerMailboxAppendObserver } from "../../state/coordination/mailbox.ts";
|
|
29
29
|
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
30
30
|
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
31
|
-
import type { TeamTaskState } from "../../state/types.ts";
|
|
31
|
+
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
32
32
|
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
33
33
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
34
34
|
import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
|
|
@@ -40,10 +40,12 @@ import { resolveCrewMaxDepth } from "../model/pi-args.ts";
|
|
|
40
40
|
import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
41
41
|
import { evaluateDelegateAdmission } from "../spawn-policy.ts";
|
|
42
42
|
import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
|
|
43
|
-
import { recordDelegateEvent } from "./delegate/delegate-event.ts";
|
|
43
|
+
import { type DelegateEventTarget, type DelegateEventType, recordDelegateEvent } from "./delegate/delegate-event.ts";
|
|
44
|
+
import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
|
|
44
45
|
import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
|
|
45
46
|
import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
|
|
46
47
|
import { handleEventsSince } from "./protocol/events-replay.ts";
|
|
48
|
+
import { DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, type LockBusyOutcome, withRunLockBusyRetry } from "./protocol/lock-busy.ts";
|
|
47
49
|
import { loadRunForHello } from "./protocol/manifest-loader.ts";
|
|
48
50
|
import { handleMsgInbox } from "./protocol/msg-inbox.ts";
|
|
49
51
|
import {
|
|
@@ -103,6 +105,7 @@ export class CrewBroker {
|
|
|
103
105
|
| "grandchildSpawner"
|
|
104
106
|
| "modelCatalog"
|
|
105
107
|
| "serializeOnPathOverlap"
|
|
108
|
+
| "lockBusyRetryDelaysMs"
|
|
106
109
|
>;
|
|
107
110
|
private readonly tokens = new BrokerTokenRegistry();
|
|
108
111
|
/** Task 10 (mux-surface A1 §5.2): taskId → the compound token most
|
|
@@ -1331,28 +1334,31 @@ export class CrewBroker {
|
|
|
1331
1334
|
}
|
|
1332
1335
|
|
|
1333
1336
|
// WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
|
|
1337
|
+
// waitAuthError: imported directly from protocol/wait-auth.ts (M4/WI-4.1).
|
|
1334
1338
|
|
|
1335
|
-
// waitAuthError: protocol/wait-auth.ts (M4/WI-4.1).
|
|
1336
|
-
private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
|
|
1337
|
-
return waitAuthError(conn);
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
// (The "ADR item 7" doc that used to dangle here documents
|
|
1341
|
-
// recordWaitPolicyRejection — see wait-auth.ts, where it belongs.
|
|
1342
|
-
// Removed 2026-09-10, review F6.)
|
|
1343
1339
|
// T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
|
|
1344
1340
|
// grandchild spawn with durable mailbox delivery (WP-5 step 5).
|
|
1345
|
-
|
|
1341
|
+
|
|
1342
|
+
/** ADR-5 §5: lazy singleton for the nested-slot budget — first use
|
|
1343
|
+
* constructs it from the broker options (4 call sites previously
|
|
1344
|
+
* inlined this IIFE). */
|
|
1345
|
+
private nestedSlotBudget(): NestedSlotBudget {
|
|
1346
|
+
if (!this.nestedSlots)
|
|
1347
|
+
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1348
|
+
return this.nestedSlots;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** RR-023 F4: bounded busy-retry around a run-locked RMW (see
|
|
1352
|
+
* protocol/lock-busy.ts) — options override or the default schedule.
|
|
1353
|
+
* A busy-exhausted result is the handler's cue to answer a typed `busy`
|
|
1354
|
+
* frame (connection survives) instead of letting the throw kill it. */
|
|
1355
|
+
private runLockBusyRetry<T>(manifest: TeamRunManifest, fn: () => T): Promise<LockBusyOutcome<T>> {
|
|
1356
|
+
return withRunLockBusyRetry(manifest, this.options.lockBusyRetryDelaysMs ?? DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, fn);
|
|
1357
|
+
}
|
|
1346
1358
|
|
|
1347
1359
|
private recordDelegateEvent(
|
|
1348
|
-
manifest:
|
|
1349
|
-
type:
|
|
1350
|
-
| "delegate.requested"
|
|
1351
|
-
| "delegate.admitted"
|
|
1352
|
-
| "delegate.rejected"
|
|
1353
|
-
| "delegate.completed"
|
|
1354
|
-
| "delegate.timed_out"
|
|
1355
|
-
| "delegate.rolled_up",
|
|
1360
|
+
manifest: DelegateEventTarget,
|
|
1361
|
+
type: DelegateEventType,
|
|
1356
1362
|
taskId: string,
|
|
1357
1363
|
data: Record<string, unknown>,
|
|
1358
1364
|
): void {
|
|
@@ -1433,7 +1439,8 @@ export class CrewBroker {
|
|
|
1433
1439
|
this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
|
|
1434
1440
|
// Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
|
|
1435
1441
|
// the RECORD under the run lock — never the worker's env/self-report.
|
|
1436
|
-
|
|
1442
|
+
// RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
|
|
1443
|
+
const admissionAttempt = await this.runLockBusyRetry(loaded.manifest, () => {
|
|
1437
1444
|
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1438
1445
|
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1439
1446
|
const task = fresh.tasks.find((t) => t.id === parentTaskId);
|
|
@@ -1449,6 +1456,10 @@ export class CrewBroker {
|
|
|
1449
1456
|
});
|
|
1450
1457
|
return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
|
|
1451
1458
|
}
|
|
1459
|
+
// RR-012 F03: authoritative execution cwd = the parent task's cwd from
|
|
1460
|
+
// this locked fresh read — ONE source for overlap check, shadow record
|
|
1461
|
+
// and spawner input; broker cwd stays for manifest lookup only.
|
|
1462
|
+
const executionCwd = task.cwd;
|
|
1452
1463
|
const catalog = this.options.modelCatalog?.();
|
|
1453
1464
|
// S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
|
|
1454
1465
|
const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
|
|
@@ -1459,7 +1470,7 @@ export class CrewBroker {
|
|
|
1459
1470
|
t.id !== parentTaskId &&
|
|
1460
1471
|
t.status === "running" &&
|
|
1461
1472
|
(t.role === "executor" || t.role === "test-engineer") &&
|
|
1462
|
-
t.cwd ===
|
|
1473
|
+
t.cwd === executionCwd,
|
|
1463
1474
|
).length;
|
|
1464
1475
|
const decision = evaluateDelegateAdmission({
|
|
1465
1476
|
maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
|
|
@@ -1469,11 +1480,7 @@ export class CrewBroker {
|
|
|
1469
1480
|
...(task.depth !== undefined ? { depth: task.depth } : {}),
|
|
1470
1481
|
...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
|
|
1471
1482
|
},
|
|
1472
|
-
slots: (()
|
|
1473
|
-
if (!this.nestedSlots)
|
|
1474
|
-
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1475
|
-
return this.nestedSlots;
|
|
1476
|
-
})().snapshot(),
|
|
1483
|
+
slots: this.nestedSlotBudget().snapshot(),
|
|
1477
1484
|
requested,
|
|
1478
1485
|
...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
|
|
1479
1486
|
// ADR-5 §12: the delegate surface is an escalation — trusted only by the
|
|
@@ -1493,28 +1500,11 @@ export class CrewBroker {
|
|
|
1493
1500
|
return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
|
|
1494
1501
|
}
|
|
1495
1502
|
// Slot acquisition INSIDE the lock (no reserve-then-race refund window).
|
|
1496
|
-
if (
|
|
1497
|
-
!(() => {
|
|
1498
|
-
if (!this.nestedSlots)
|
|
1499
|
-
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1500
|
-
return this.nestedSlots;
|
|
1501
|
-
})().tryAcquire(subId)
|
|
1502
|
-
) {
|
|
1503
|
+
if (!this.nestedSlotBudget().tryAcquire(subId)) {
|
|
1503
1504
|
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
|
|
1504
1505
|
return {
|
|
1505
1506
|
code: "policy-denied" as const,
|
|
1506
|
-
message: `delegate rejected: nested spawn budget exhausted; ${
|
|
1507
|
-
(
|
|
1508
|
-
() => {
|
|
1509
|
-
if (!this.nestedSlots)
|
|
1510
|
-
this.nestedSlots = new NestedSlotBudget(
|
|
1511
|
-
this.options.globalWorkerSemaphore ?? 4,
|
|
1512
|
-
this.options.nestingMaxSlots,
|
|
1513
|
-
);
|
|
1514
|
-
return this.nestedSlots;
|
|
1515
|
-
}
|
|
1516
|
-
)().statusLine
|
|
1517
|
-
}`,
|
|
1507
|
+
message: `delegate rejected: nested spawn budget exhausted; ${this.nestedSlotBudget().statusLine}`,
|
|
1518
1508
|
};
|
|
1519
1509
|
}
|
|
1520
1510
|
// Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
|
|
@@ -1551,19 +1541,29 @@ export class CrewBroker {
|
|
|
1551
1541
|
agent: "delegate",
|
|
1552
1542
|
title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
|
|
1553
1543
|
status: "queued",
|
|
1554
|
-
cwd:
|
|
1544
|
+
cwd: executionCwd,
|
|
1555
1545
|
dependsOn: [],
|
|
1556
1546
|
depth: decision.childDepth ?? 2,
|
|
1557
1547
|
startedAt: new Date().toISOString(),
|
|
1558
1548
|
} satisfies TeamTaskState,
|
|
1559
1549
|
]);
|
|
1560
|
-
|
|
1550
|
+
// RR-023 F3: thread the parent's model out of the locked read (gc fallback).
|
|
1551
|
+
return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model };
|
|
1561
1552
|
});
|
|
1553
|
+
// RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
|
|
1554
|
+
// degrade to a typed busy frame AND a delegate.rejected event (never
|
|
1555
|
+
// silent); the connection survives (ping still answers).
|
|
1556
|
+
if (!admissionAttempt.ok) {
|
|
1557
|
+
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
|
|
1558
|
+
this.sendError(conn, id, "busy", admissionAttempt.message);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const admissionOutcome = admissionAttempt.value;
|
|
1562
1562
|
if (admissionOutcome.code !== "ok") {
|
|
1563
1563
|
this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
|
|
1564
1564
|
return;
|
|
1565
1565
|
}
|
|
1566
|
-
const { decision, reserved } = admissionOutcome;
|
|
1566
|
+
const { decision, reserved, executionCwd, parentModel } = admissionOutcome;
|
|
1567
1567
|
this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
|
|
1568
1568
|
subId,
|
|
1569
1569
|
childDepth: decision.childDepth,
|
|
@@ -1579,13 +1579,16 @@ export class CrewBroker {
|
|
|
1579
1579
|
: undefined;
|
|
1580
1580
|
// Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
|
|
1581
1581
|
this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
|
|
1582
|
+
// RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
|
|
1583
|
+
// through to the pi GLOBAL default — inherit the parent task's model.
|
|
1584
|
+
const gcModel = requested.model ?? parentModel;
|
|
1582
1585
|
// Background grandchild lifecycle (ADR-5 §1/§6).
|
|
1583
1586
|
const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
|
|
1584
1587
|
void (async () => {
|
|
1585
1588
|
let outcome: GrandchildSpawnResult;
|
|
1586
1589
|
try {
|
|
1587
1590
|
outcome = await spawner({
|
|
1588
|
-
cwd,
|
|
1591
|
+
cwd: executionCwd,
|
|
1589
1592
|
runId,
|
|
1590
1593
|
parentTaskId,
|
|
1591
1594
|
subId,
|
|
@@ -1593,10 +1596,14 @@ export class CrewBroker {
|
|
|
1593
1596
|
eventsPath: loaded.manifest.eventsPath,
|
|
1594
1597
|
role: requested.role ?? "explorer",
|
|
1595
1598
|
...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
|
|
1596
|
-
...(
|
|
1599
|
+
...(gcModel !== undefined ? { model: gcModel } : {}),
|
|
1597
1600
|
...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
|
|
1598
1601
|
timeoutSec: decision.timeoutSec ?? 900,
|
|
1599
1602
|
depthOverride: decision.childDepth ?? 2,
|
|
1603
|
+
// RR-012 F16: promote shadow queued→running once the process exists.
|
|
1604
|
+
onSpawn: (pid) => {
|
|
1605
|
+
if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
|
|
1606
|
+
},
|
|
1600
1607
|
});
|
|
1601
1608
|
} catch (err) {
|
|
1602
1609
|
outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
|
|
@@ -1649,12 +1656,9 @@ export class CrewBroker {
|
|
|
1649
1656
|
});
|
|
1650
1657
|
}
|
|
1651
1658
|
}
|
|
1652
|
-
// S1#1:
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
1655
|
-
// shadow "queued" forever.
|
|
1656
|
-
// S1#1: flip the shadow task to terminal so the subId record does not
|
|
1657
|
-
// linger as "queued" in team status views.
|
|
1659
|
+
// S1#1 (verifier N1): the terminal flip is UNCONDITIONAL — never depends
|
|
1660
|
+
// on a reservation. RR-012 F16: onSpawn promotes the record to "running"
|
|
1661
|
+
// during execution; this flip terminalizes every settle path.
|
|
1658
1662
|
saveRunTasks(
|
|
1659
1663
|
latest.manifest,
|
|
1660
1664
|
tasksToWrite.map((t) =>
|
|
@@ -1705,11 +1709,7 @@ export class CrewBroker {
|
|
|
1705
1709
|
}
|
|
1706
1710
|
}
|
|
1707
1711
|
} finally {
|
|
1708
|
-
(()
|
|
1709
|
-
if (!this.nestedSlots)
|
|
1710
|
-
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1711
|
-
return this.nestedSlots;
|
|
1712
|
-
})().release(subId);
|
|
1712
|
+
this.nestedSlotBudget().release(subId);
|
|
1713
1713
|
}
|
|
1714
1714
|
this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
|
|
1715
1715
|
subId,
|
|
@@ -1718,9 +1718,7 @@ export class CrewBroker {
|
|
|
1718
1718
|
})();
|
|
1719
1719
|
}
|
|
1720
1720
|
|
|
1721
|
-
|
|
1722
|
-
recordWaitPolicyRejection(manifest, taskId, method);
|
|
1723
|
-
}
|
|
1721
|
+
// recordWaitPolicyRejection: imported directly from protocol/wait-auth.ts.
|
|
1724
1722
|
|
|
1725
1723
|
/** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
|
|
1726
1724
|
* leader answer. Park = task.status "waiting" + task.waiting marker +
|
|
@@ -1735,7 +1733,7 @@ export class CrewBroker {
|
|
|
1735
1733
|
this.sendError(conn, id, "auth", "not authed");
|
|
1736
1734
|
return;
|
|
1737
1735
|
}
|
|
1738
|
-
const authErr =
|
|
1736
|
+
const authErr = waitAuthError(conn);
|
|
1739
1737
|
if (authErr) {
|
|
1740
1738
|
this.sendError(conn, id, authErr.code, authErr.message);
|
|
1741
1739
|
return;
|
|
@@ -1772,7 +1770,7 @@ export class CrewBroker {
|
|
|
1772
1770
|
// Capability gate (ADR item 7): fail-closed, NEVER silent — every
|
|
1773
1771
|
// rejection leaves a policy.action trace in the run's events.jsonl.
|
|
1774
1772
|
if (this.options.waitMethodsEnabled !== true) {
|
|
1775
|
-
|
|
1773
|
+
recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.request");
|
|
1776
1774
|
this.sendError(
|
|
1777
1775
|
conn,
|
|
1778
1776
|
id,
|
|
@@ -1790,7 +1788,8 @@ export class CrewBroker {
|
|
|
1790
1788
|
const deadline = Date.now() + clampSec * 1000;
|
|
1791
1789
|
const runId = conn.runId;
|
|
1792
1790
|
const taskId = conn.taskId;
|
|
1793
|
-
|
|
1791
|
+
// RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
|
|
1792
|
+
const outcome = await this.runLockBusyRetry(loaded.manifest, () => {
|
|
1794
1793
|
// Fresh reload INSIDE the lock (respond.ts:42-43 discipline).
|
|
1795
1794
|
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1796
1795
|
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
@@ -1823,8 +1822,14 @@ export class CrewBroker {
|
|
|
1823
1822
|
saveRunManifest(updatedManifest);
|
|
1824
1823
|
return { code: "ok" as const, message: "" };
|
|
1825
1824
|
});
|
|
1826
|
-
|
|
1827
|
-
|
|
1825
|
+
// RR-023 F4 (finding #4): run.lock held by a live cross-process holder
|
|
1826
|
+
// (e.g. the detached runner) — typed busy frame, connection survives.
|
|
1827
|
+
if (!outcome.ok) {
|
|
1828
|
+
this.sendError(conn, id, "busy", outcome.message);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (outcome.value.code !== "ok") {
|
|
1832
|
+
this.sendError(conn, id, outcome.value.code, outcome.value.message);
|
|
1828
1833
|
return;
|
|
1829
1834
|
}
|
|
1830
1835
|
// Events AFTER the run lock is released (the event-log lock is a
|
|
@@ -1889,7 +1894,7 @@ export class CrewBroker {
|
|
|
1889
1894
|
this.sendError(conn, id, "auth", "not authed");
|
|
1890
1895
|
return;
|
|
1891
1896
|
}
|
|
1892
|
-
const authErr =
|
|
1897
|
+
const authErr = waitAuthError(conn);
|
|
1893
1898
|
if (authErr) {
|
|
1894
1899
|
this.sendError(conn, id, authErr.code, authErr.message);
|
|
1895
1900
|
return;
|
|
@@ -1922,7 +1927,7 @@ export class CrewBroker {
|
|
|
1922
1927
|
return;
|
|
1923
1928
|
}
|
|
1924
1929
|
if (this.options.waitMethodsEnabled !== true) {
|
|
1925
|
-
|
|
1930
|
+
recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
|
|
1926
1931
|
this.sendError(
|
|
1927
1932
|
conn,
|
|
1928
1933
|
id,
|
|
@@ -13,15 +13,19 @@ import { logInternalError } from "../../../utils/internal-error.ts";
|
|
|
13
13
|
/** Fire-and-forget async append; an append failure is logged, never thrown
|
|
14
14
|
* (broker handlers must not block the event loop on the sync event-log lock).
|
|
15
15
|
* See `crew-broker.delegate.event` event scope conventions in ADR-5 §10. */
|
|
16
|
+
export type DelegateEventType =
|
|
17
|
+
| "delegate.requested"
|
|
18
|
+
| "delegate.admitted"
|
|
19
|
+
| "delegate.rejected"
|
|
20
|
+
| "delegate.completed"
|
|
21
|
+
| "delegate.timed_out"
|
|
22
|
+
| "delegate.rolled_up";
|
|
23
|
+
|
|
24
|
+
export type DelegateEventTarget = { eventsPath: string; runId: string };
|
|
25
|
+
|
|
16
26
|
export function recordDelegateEvent(
|
|
17
|
-
manifest:
|
|
18
|
-
type:
|
|
19
|
-
| "delegate.requested"
|
|
20
|
-
| "delegate.admitted"
|
|
21
|
-
| "delegate.rejected"
|
|
22
|
-
| "delegate.completed"
|
|
23
|
-
| "delegate.timed_out"
|
|
24
|
-
| "delegate.rolled_up",
|
|
27
|
+
manifest: DelegateEventTarget,
|
|
28
|
+
type: DelegateEventType,
|
|
25
29
|
taskId: string,
|
|
26
30
|
data: Record<string, unknown>,
|
|
27
31
|
): void {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shadow-lifecycle.ts — broker-owned lifecycle for delegate grandchild SHADOW
|
|
3
|
+
* task records (RR-012, findings F03/F16; ADR-5 §1/§3 S1#1).
|
|
4
|
+
*
|
|
5
|
+
* The `delegate.request` handler (crew-broker.ts) mints a `gc-*` shadow task
|
|
6
|
+
* (agent "delegate", NO stepId) so the subId identity gets a real depth/role
|
|
7
|
+
* entry. The record is NOT a workflow task: it is managed by the EXTERNAL
|
|
8
|
+
* grandchild spawner (the broker), never by the workflow scheduler.
|
|
9
|
+
* isDelegateShadowTask() is the single discriminator the scheduler uses to
|
|
10
|
+
* keep shadow records out of DAG/batch selection — they carry no stepId, and
|
|
11
|
+
* dispatching one would throw ResourceNotFound in findStep()/findAgent()
|
|
12
|
+
* (dispatch-batch.ts; RR-012 §3 reachability proof in
|
|
13
|
+
* test/unit/runtime/broker/shadow-task-dag-readiness.test.ts).
|
|
14
|
+
*
|
|
15
|
+
* BR-09 (discriminator correctness): the marker is STRUCTURAL — the absence of
|
|
16
|
+
* `stepId` — NOT the agent name. Every scheduler-managed task sets
|
|
17
|
+
* `stepId: step.id` (state-store.ts materializeTasks), so "no stepId" means
|
|
18
|
+
* "not a workflow task". The previous test (`agent === "delegate"`) filtered
|
|
19
|
+
* out any LEGITIMATE workflow task whose resolved agent happens to be named
|
|
20
|
+
* `delegate` — such a task never entered a batch, was never dispatched, and
|
|
21
|
+
* finalize-run then marked the whole run `blocked`. (`delegate` is deliberately
|
|
22
|
+
* NOT in PROTECTED_AGENT_NAMES, so the name itself carries no reserved
|
|
23
|
+
* meaning.) Backward compatible both ways: legacy persisted shadows also lack
|
|
24
|
+
* `stepId` (still excluded) and legacy workflow tasks carry one (still
|
|
25
|
+
* dispatched); no schema change.
|
|
26
|
+
*
|
|
27
|
+
* KNOWN overlap (cold-verify): `reconstructTasksFromEventLog`
|
|
28
|
+
* (manifest-io.ts, ST-4 corruption recovery) also emits tasks WITHOUT `stepId`
|
|
29
|
+
* — those are classified as shadows and excluded from dispatch selection.
|
|
30
|
+
* That is fail-closed, and strictly better than the pre-guard behavior
|
|
31
|
+
* (findStep would throw ResourceNotFound and kill the batch): reconstructed
|
|
32
|
+
* tasks carry agent "unknown" and no step, so they cannot be dispatched
|
|
33
|
+
* anyway. Do NOT reuse this predicate to mean "broker shadow" outside
|
|
34
|
+
* scheduler selection.
|
|
35
|
+
*
|
|
36
|
+
* F16 lifecycle contract (broker-owned; every write under the run lock):
|
|
37
|
+
* queued → saved by the admission write (crew-broker.ts handleDelegateRequest)
|
|
38
|
+
* running → promoteShadowToRunning() from the spawner's onSpawn hook the
|
|
39
|
+
* moment the grandchild process exists (pid !== null). Idempotent:
|
|
40
|
+
* only a record still "queued" is promoted — a terminal flip or a
|
|
41
|
+
* stale-reconciler cancel always wins over a late promote.
|
|
42
|
+
* completed | failed → the unconditional terminal flip when the spawner
|
|
43
|
+
* settles (success, failure, error, timeout — every path).
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
47
|
+
import { loadRunManifestById, saveRunTasks } from "../../../state/stores/state-store.ts";
|
|
48
|
+
import type { TeamTaskState } from "../../../state/types.ts";
|
|
49
|
+
import { logInternalError } from "../../../utils/internal-error.ts";
|
|
50
|
+
|
|
51
|
+
/** Shadow-task discriminator (RR-012 + BR-09): a task record managed by the
|
|
52
|
+
* external delegate-broker spawner rather than the workflow scheduler.
|
|
53
|
+
*
|
|
54
|
+
* The marker is STRUCTURAL: shadow records are persisted with NO `stepId`
|
|
55
|
+
* (the broker's shadow literal in crew-broker.ts sets none), while every
|
|
56
|
+
* scheduler-materialized task sets `stepId: step.id`. A record without a
|
|
57
|
+
* stepId cannot be dispatched anyway — `findStep()` matches on `stepId` and
|
|
58
|
+
* throws ResourceNotFound — so "no stepId" is exactly the set the scheduler
|
|
59
|
+
* must skip. The agent name is NOT part of the predicate (BR-09: a real
|
|
60
|
+
* workflow task may legitimately resolve to an agent named `delegate`). */
|
|
61
|
+
export function isDelegateShadowTask(task: Pick<TeamTaskState, "id" | "agent" | "stepId">): boolean {
|
|
62
|
+
return task.stepId === undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** F16: queued→running promote, called from the spawner's onSpawn hook when
|
|
66
|
+
* the grandchild process exists. Best-effort by design: a failure logs and
|
|
67
|
+
* leaves the record as-is — the unconditional terminal flip on settle still
|
|
68
|
+
* runs, so a promote failure can never strand a record in "queued" past the
|
|
69
|
+
* settle (and depth-3 admission merely stays blocked until then, which is
|
|
70
|
+
* the fail-closed direction). */
|
|
71
|
+
export function promoteShadowToRunning(brokerCwd: string, runId: string, subId: string): void {
|
|
72
|
+
try {
|
|
73
|
+
const fresh = loadRunManifestById(brokerCwd, runId);
|
|
74
|
+
if (!fresh) return;
|
|
75
|
+
withRunLockSync(fresh.manifest, () => {
|
|
76
|
+
const latest = loadRunManifestById(brokerCwd, runId);
|
|
77
|
+
if (!latest) return;
|
|
78
|
+
const shadow = latest.tasks.find((t) => t.id === subId);
|
|
79
|
+
if (shadow?.status !== "queued") return;
|
|
80
|
+
saveRunTasks(
|
|
81
|
+
latest.manifest,
|
|
82
|
+
latest.tasks.map((t) => (t.id === subId ? { ...t, status: "running" as const } : t)),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
} catch (err) {
|
|
86
|
+
logInternalError(
|
|
87
|
+
"crew-broker.delegate.promote-shadow",
|
|
88
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
89
|
+
`runId=${runId} subId=${subId}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -55,6 +55,16 @@ export interface CrewBrokerOptions {
|
|
|
55
55
|
nestingTrustedEscalation?: boolean;
|
|
56
56
|
/** Global worker semaphore size, used to size the nested-slot budget. */
|
|
57
57
|
globalWorkerSemaphore?: number;
|
|
58
|
+
/** RR-023 F4 (2026-09-29 battery, finding #4): bounded retry schedule
|
|
59
|
+
* (ms) for run.lock contention in the wait.request / delegate.request
|
|
60
|
+
* handlers. A live cross-process holder (e.g. the detached runner
|
|
61
|
+
* persisting task state) makes acquireLockWithRetry throw; instead of
|
|
62
|
+
* letting that escape handleData → closeConnection, the handler retries
|
|
63
|
+
* on this schedule and then answers a typed `busy` error frame with the
|
|
64
|
+
* connection SURVIVING (plus a delegate.rejected event on the delegate
|
|
65
|
+
* path — never silent). Default DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS
|
|
66
|
+
* (50/100/200/400/800). See protocol/lock-busy.ts. */
|
|
67
|
+
lockBusyRetryDelaysMs?: readonly number[];
|
|
58
68
|
/** Test seam / alternative spawner for delegate grandchildren. Production
|
|
59
69
|
* uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
|
|
60
70
|
grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lock-busy.ts — RR-023 F4 (2026-09-29 full battery, finding #4).
|
|
3
|
+
*
|
|
4
|
+
* Broker-handler degradation for run.lock contention. Root cause: the
|
|
5
|
+
* wait.request / delegate.request handlers run their state RMW inside
|
|
6
|
+
* withRunLockSync; acquireLockWithRetry (locks.ts, canSteal=false) throws
|
|
7
|
+
* IMMEDIATELY when a live cross-process holder (e.g. the detached runner
|
|
8
|
+
* persisting task state) owns run.lock, and that throw escaped handleData →
|
|
9
|
+
* closeConnection — workers saw an untyped `code=close` socket death
|
|
10
|
+
* (evidence: team_20260929041005 ask-probe / team_20260929041020
|
|
11
|
+
* delegate-probe, both dead within ~5ms; the SYNC foreground run was the
|
|
12
|
+
* zero-contention control).
|
|
13
|
+
*
|
|
14
|
+
* Fix layer is the broker ONLY — locks.ts steal semantics are the v0.9.26
|
|
15
|
+
* lock-family invariant and stay untouched (a live holder is never stolen).
|
|
16
|
+
* Precedent: protocol/request-parsers.ts (M4/WI-4.1 extraction discipline).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { withRunLockSync } from "../../../state/coordination/locks.ts";
|
|
20
|
+
import type { TeamRunManifest } from "../../../state/types.ts";
|
|
21
|
+
|
|
22
|
+
/** Default bounded retry schedule for run.lock contention (ms). Five
|
|
23
|
+
* back-off attempts (~1.55s total) absorb a transient holder — e.g. the
|
|
24
|
+
* detached runner's state flush — without parking the request. */
|
|
25
|
+
export const DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS: readonly number[] = [50, 100, 200, 400, 800];
|
|
26
|
+
|
|
27
|
+
/** Match the exact identity locks.ts throws for a LIVE holder on the
|
|
28
|
+
* never-steal path (sync acquireLockWithRetry, its async twin, and the
|
|
29
|
+
* lockCtx fallthrough): `Run '<basename>' is locked by another operation.`
|
|
30
|
+
* Classification is by message identity — locks.ts exports no error class
|
|
31
|
+
* (plain Error), and src/state must not be edited from the broker layer. */
|
|
32
|
+
const RUN_LOCK_BUSY_MESSAGE = /^Run '[^']+' is locked by another operation\.$/;
|
|
33
|
+
|
|
34
|
+
export function isRunLockBusyError(error: unknown): boolean {
|
|
35
|
+
return error instanceof Error && RUN_LOCK_BUSY_MESSAGE.test(error.message);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Result of a run-locked RMW under the busy-retry policy: either the body's
|
|
39
|
+
* value, or the typed busy degradation (message derived from the underlying
|
|
40
|
+
* lock error — it names run.lock, so it matches /run\.lock|busy/i). */
|
|
41
|
+
export type LockBusyOutcome<T> = { ok: true; value: T } | { ok: false; message: string };
|
|
42
|
+
|
|
43
|
+
/** Run `fn` under withRunLockSync with a bounded busy-retry.
|
|
44
|
+
*
|
|
45
|
+
* Retry safety: the busy throw is raised by the lock ACQUIRE before `fn`
|
|
46
|
+
* runs (locks.ts), and in the broker's wait/delegate bodies every nested
|
|
47
|
+
* state write is lockCtx-reentrant (same-context nesting never re-acquires),
|
|
48
|
+
* so a retry never re-executes a partially-run body. Any NON-busy error
|
|
49
|
+
* propagates unchanged (previous handler behavior for real faults).
|
|
50
|
+
* Waits use async sleep — the broker event loop keeps serving other
|
|
51
|
+
* connections (and pings) between attempts. */
|
|
52
|
+
export async function withRunLockBusyRetry<T>(
|
|
53
|
+
manifest: TeamRunManifest,
|
|
54
|
+
delaysMs: readonly number[],
|
|
55
|
+
fn: () => T,
|
|
56
|
+
): Promise<LockBusyOutcome<T>> {
|
|
57
|
+
for (let attempt = 0; ; attempt++) {
|
|
58
|
+
try {
|
|
59
|
+
return { ok: true, value: withRunLockSync(manifest, fn) };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!isRunLockBusyError(error)) throw error;
|
|
62
|
+
const delay = delaysMs[attempt];
|
|
63
|
+
if (delay === undefined) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
message: `run.lock busy (bounded retry budget exhausted): ${(error as Error).message}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
await sleep(delay);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sleep(ms: number): Promise<void> {
|
|
75
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* wait-status-cache.ts — R10-3: mtime+size-gated load cache for task.waitStatus.
|
|
3
3
|
*
|
|
4
|
-
* Problem (docs/refactor-plan.review.md §Round 10, R10-3): the broker's
|
|
4
|
+
* Problem (docs/archive/refactor-plan.review.md §Round 10, R10-3): the broker's
|
|
5
5
|
* task.waitStatus poll loop ticks every 200ms per waiter and each tick calls
|
|
6
6
|
* loadRunManifestById() — stat manifest + stat tasks + (on the state-store
|
|
7
7
|
* manifest-cache miss, which every write invalidates via generation bump) a
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* `deps` (values) + `state` (getters/setters over the runChildPi-local flags)
|
|
21
21
|
* so behavior is preserved byte-for-byte.
|
|
22
22
|
*
|
|
23
|
-
* R6-F1 (refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
|
|
23
|
+
* R6-F1 (docs/archive/refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
|
|
24
24
|
* constructs — previously cancelHardKill was a local const inside abort() and
|
|
25
25
|
* was NOT cleared by clearChildPiTimeouts(), leaking a 200 ms unref'd timer per
|
|
26
26
|
* cancel on short-lived children.
|
|
@@ -131,6 +131,54 @@ export async function runMockChildPi(
|
|
|
131
131
|
stderr: "[MOCK] rate limit: mock failure",
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
// RR-013 (F04) test seam: reproduce the MuxSurface A1 degrade result WITHOUT a
|
|
135
|
+
// real multiplexer. child-executor's `childResult.surface?.degraded` branch is
|
|
136
|
+
// the ONLY producer of TaskExecutionResult.surfaceLost, and runTeamTask offers
|
|
137
|
+
// no seam to inject `surface.providers` (child-executor's runWorker() call
|
|
138
|
+
// passes no `surface` field), so no other mock mode can reach that branch.
|
|
139
|
+
//
|
|
140
|
+
// SCOPE NOTE: this mode does NOT simulate `classifyOnExit` (the 2s window) — it
|
|
141
|
+
// starts from the exact point where classifyOnExit already returned
|
|
142
|
+
// `degraded`. That is the correct scope: the F04 defect lives at the
|
|
143
|
+
// child-executor → task-runner → finalizer boundary, not in classify (which is
|
|
144
|
+
// covered by degrade.test.ts + child-pi-surface.test.ts).
|
|
145
|
+
//
|
|
146
|
+
// Guarded by the existing PI_CREW_ALLOW_MOCK parent-only check above, so it
|
|
147
|
+
// cannot be activated from a child worker.
|
|
148
|
+
if (mock === "surface-degraded") {
|
|
149
|
+
return {
|
|
150
|
+
exitCode: 0,
|
|
151
|
+
stdout: "",
|
|
152
|
+
stderr: "",
|
|
153
|
+
rawFinalText: "",
|
|
154
|
+
surface: {
|
|
155
|
+
kind: "tmux",
|
|
156
|
+
paneId: "%9",
|
|
157
|
+
scriptPath: "/tmp/pi-crew-mock-launch.sh",
|
|
158
|
+
degraded: {
|
|
159
|
+
cause: "pane-closed",
|
|
160
|
+
exitReason: "pane-closed",
|
|
161
|
+
classifiedAt: new Date().toISOString(),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// RR-013 (AC-2) test seam: the ONLY result channel carrying text is
|
|
168
|
+
// `rawFinalText` (stdout/transcript are empty). This reproduces the compaction
|
|
169
|
+
// scenario the TaskExecutionResult.rawFinalText docstring describes —
|
|
170
|
+
// parsedOutput.finalText and finalStdout are both empty, so the spec-evidence
|
|
171
|
+
// footer union can only see the text if rawFinalText survives the
|
|
172
|
+
// child-executor → task-runner → finalizer boundary.
|
|
173
|
+
if (mock === "raw-final-text-only") {
|
|
174
|
+
return {
|
|
175
|
+
exitCode: 0,
|
|
176
|
+
stdout: "",
|
|
177
|
+
stderr: "",
|
|
178
|
+
rawFinalText: "RR-013 boundary probe result.\n\nSPEC-EVIDENCE:\nacc-1: ran the boundary check\n",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
134
182
|
// E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
|
|
135
183
|
// failure (exit code 0, no real assistant text, message_end carries a
|
|
136
184
|
// retryable-pattern errorMessage). Invocation #2+ delegates to the
|