pi-crew 0.11.2 → 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 +44 -1
- package/dist/index.mjs +491 -493
- package/package.json +5 -2
- package/skills/real-test-pi-crew/SKILL.md +19 -11
- package/src/extension/async-notifier.ts +32 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/commands/shared.ts +11 -10
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/lazy-configurers.ts +4 -1
- package/src/extension/registration/lifecycle-handlers.ts +16 -10
- package/src/extension/registration/lifecycle.ts +2 -0
- package/src/extension/registration/observability.ts +27 -18
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +2 -0
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +12 -0
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/runtime/async-runner.ts +36 -0
- package/src/runtime/broker/crew-broker.ts +61 -62
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/delegate-spawn.ts +16 -2
- package/src/runtime/deterministic-ast.ts +14 -2
- 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 +32 -0
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- 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 +75 -52
- 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/prompt-builder.ts +8 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +7 -0
- package/src/runtime/team-runner.ts +4 -1
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/state/coordination/mailbox.ts +7 -2
- package/src/state/stores/state-store.ts +8 -1
- package/src/ui/dashboard-panes/agents-pane.ts +11 -8
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +11 -1
- package/src/ui/run-snapshot-cache.ts +9 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/tsconfig.json +2 -0
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -906,67 +906,87 @@ function describeSchemaShape(schema: unknown, depth: number): string {
|
|
|
906
906
|
* reject is checked first because a review can mention both "correctly" (describing existing code)
|
|
907
907
|
* AND "critical bug" (the verdict) — the verdict signal must win.
|
|
908
908
|
*/
|
|
909
|
+
// Precompiled review-outcome signals (module scope — compiled once, not per call).
|
|
910
|
+
// RR-021 WI-1.1: these were previously string literals like "\baccept\b" compiled
|
|
911
|
+
// per call — and "\b" in a TS string literal is a BACKSPACE character (U+0008),
|
|
912
|
+
// so those two signals never matched anything. Regex literals here give real word
|
|
913
|
+
// boundaries. No /g flags → no shared lastIndex state across .test() calls.
|
|
914
|
+
const REJECT_SIGNAL_REGEXES: readonly RegExp[] = [
|
|
915
|
+
/\breject\b/,
|
|
916
|
+
/fundamentally/,
|
|
917
|
+
/completely broken/,
|
|
918
|
+
/totally broken/,
|
|
919
|
+
/critical bug/,
|
|
920
|
+
/critical issue/,
|
|
921
|
+
/critical flaw/,
|
|
922
|
+
/security vulnerability/,
|
|
923
|
+
/does not work/,
|
|
924
|
+
/doesn't work/,
|
|
925
|
+
/will not work/,
|
|
926
|
+
/fails to/,
|
|
927
|
+
/unacceptable/,
|
|
928
|
+
/must not be merged/,
|
|
929
|
+
/do not merge/,
|
|
930
|
+
/wrong approach/,
|
|
931
|
+
/logically incorrect/,
|
|
932
|
+
/incorrectly implements/,
|
|
933
|
+
/returns the opposite/,
|
|
934
|
+
/subtraction instead of addition/,
|
|
935
|
+
/opposite of its intended/,
|
|
936
|
+
];
|
|
937
|
+
const ACCEPT_SIGNAL_REGEXES: readonly RegExp[] = [
|
|
938
|
+
/\baccept\b/,
|
|
939
|
+
/looks good/,
|
|
940
|
+
/well done/,
|
|
941
|
+
/no issues/,
|
|
942
|
+
/no real issues/,
|
|
943
|
+
/no problems/,
|
|
944
|
+
/no concerns/,
|
|
945
|
+
/nothing to change/,
|
|
946
|
+
/ready to merge/,
|
|
947
|
+
/lgtm/,
|
|
948
|
+
/ship it/,
|
|
949
|
+
/correctly implements/,
|
|
950
|
+
/correctly returns/,
|
|
951
|
+
/works as expected/,
|
|
952
|
+
/works correctly/,
|
|
953
|
+
/no bugs/,
|
|
954
|
+
/no defects/,
|
|
955
|
+
/meets all requirements/,
|
|
956
|
+
/all requirements met/,
|
|
957
|
+
/passes all/,
|
|
958
|
+
/is correct/,
|
|
959
|
+
/are correct/,
|
|
960
|
+
/no changes needed/,
|
|
961
|
+
/no changes required/,
|
|
962
|
+
/no further changes/,
|
|
963
|
+
/nothing more to/,
|
|
964
|
+
/complete and correct/,
|
|
965
|
+
/sound implementation/,
|
|
966
|
+
];
|
|
967
|
+
// RR-021 WI-1.1 negation guard: phrases that NEGATE the bare "accept" signal.
|
|
968
|
+
// "I cannot accept this" is a complaint, not approval — the accept hit must be
|
|
969
|
+
// discarded so the outcome falls through to changes_requested (or reject if a
|
|
970
|
+
// reject signal is also present).
|
|
971
|
+
const NEGATED_ACCEPT_REGEXES: readonly RegExp[] = [
|
|
972
|
+
/\bcannot\s+accept\b/,
|
|
973
|
+
/\bcan'?t\s+accept\b/,
|
|
974
|
+
/\bdon'?t\s+accept\b/,
|
|
975
|
+
/\bwon'?t\s+accept\b/,
|
|
976
|
+
/\bdo\s+not\s+accept\b/,
|
|
977
|
+
/\bwill\s+not\s+accept\b/,
|
|
978
|
+
/\bunable\s+to\s+accept\b/,
|
|
979
|
+
/\bnot\s+accept(?:ed|ing|able)?\b/,
|
|
980
|
+
];
|
|
981
|
+
|
|
909
982
|
export function classifyReviewOutcome(prose: string): "accept" | "reject" | "changes_requested" {
|
|
910
983
|
const text = prose.toLowerCase();
|
|
911
|
-
//
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
"fundamentally",
|
|
915
|
-
"completely broken",
|
|
916
|
-
"totally broken",
|
|
917
|
-
"critical bug",
|
|
918
|
-
"critical issue",
|
|
919
|
-
"critical flaw",
|
|
920
|
-
"security vulnerability",
|
|
921
|
-
"does not work",
|
|
922
|
-
"doesn't work",
|
|
923
|
-
"will not work",
|
|
924
|
-
"fails to",
|
|
925
|
-
"unacceptable",
|
|
926
|
-
"must not be merged",
|
|
927
|
-
"do not merge",
|
|
928
|
-
"wrong approach",
|
|
929
|
-
"logically incorrect",
|
|
930
|
-
"incorrectly implements",
|
|
931
|
-
"returns the opposite",
|
|
932
|
-
"subtraction instead of addition",
|
|
933
|
-
"opposite of its intended",
|
|
934
|
-
];
|
|
935
|
-
// Acceptance signals → accept. These indicate explicit approval with no real issues.
|
|
936
|
-
const acceptSignals = [
|
|
937
|
-
"\baccept\b",
|
|
938
|
-
"looks good",
|
|
939
|
-
"well done",
|
|
940
|
-
"no issues",
|
|
941
|
-
"no real issues",
|
|
942
|
-
"no problems",
|
|
943
|
-
"no concerns",
|
|
944
|
-
"nothing to change",
|
|
945
|
-
"ready to merge",
|
|
946
|
-
"lgtm",
|
|
947
|
-
"ship it",
|
|
948
|
-
"correctly implements",
|
|
949
|
-
"correctly returns",
|
|
950
|
-
"works as expected",
|
|
951
|
-
"works correctly",
|
|
952
|
-
"no bugs",
|
|
953
|
-
"no defects",
|
|
954
|
-
"meets all requirements",
|
|
955
|
-
"all requirements met",
|
|
956
|
-
"passes all",
|
|
957
|
-
"is correct",
|
|
958
|
-
"are correct",
|
|
959
|
-
"no changes needed",
|
|
960
|
-
"no changes required",
|
|
961
|
-
"no further changes",
|
|
962
|
-
"nothing more to",
|
|
963
|
-
"complete and correct",
|
|
964
|
-
"sound implementation",
|
|
965
|
-
];
|
|
966
|
-
const hasReject = rejectSignals.some((sig) => new RegExp(sig).test(text));
|
|
967
|
-
const hasAccept = acceptSignals.some((sig) => new RegExp(sig).test(text));
|
|
984
|
+
// Reject-first precedence: a review can mention both "correctly" (describing
|
|
985
|
+
// existing code) AND "critical bug" (the verdict) — the verdict signal must win.
|
|
986
|
+
const hasReject = REJECT_SIGNAL_REGEXES.some((re) => re.test(text));
|
|
968
987
|
if (hasReject) return "reject";
|
|
969
|
-
|
|
988
|
+
const hasAccept = ACCEPT_SIGNAL_REGEXES.some((re) => re.test(text));
|
|
989
|
+
if (hasAccept && !NEGATED_ACCEPT_REGEXES.some((re) => re.test(text))) return "accept";
|
|
970
990
|
return "changes_requested";
|
|
971
991
|
}
|
|
972
992
|
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
21
22
|
import { join } from "node:path";
|
|
22
|
-
import { transformSync } from "esbuild";
|
|
23
23
|
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
24
24
|
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
25
25
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
@@ -33,6 +33,18 @@ import { assertDeterministicScript, isDeterminismCheckEnabled } from "../determi
|
|
|
33
33
|
import { DwfStore } from "../dwf-state-store.ts";
|
|
34
34
|
import { getWorkflowFinalResult, getWorkflowPhaseState, makeWorkflowCtx } from "./dynamic-workflow-context.ts";
|
|
35
35
|
|
|
36
|
+
// RR-021 WI-2.1: esbuild is a heavy native dep (~35ms+ cold load) but is only
|
|
37
|
+
// needed when a .dwf.ts script actually executes. Lazy-load via a synchronous
|
|
38
|
+
// createRequire shim on first use (model: ui/syntax-highlight.ts) — call sites
|
|
39
|
+
// stay sync. esbuild remains external in the bundle and resolves through here.
|
|
40
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
41
|
+
type EsbuildModule = typeof import("esbuild");
|
|
42
|
+
let cachedEsbuild: EsbuildModule | undefined;
|
|
43
|
+
function esbuild(): EsbuildModule {
|
|
44
|
+
if (!cachedEsbuild) cachedEsbuild = nodeRequire("esbuild") as EsbuildModule;
|
|
45
|
+
return cachedEsbuild;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
export interface RunDynamicWorkflowInput {
|
|
37
49
|
manifest: TeamRunManifest;
|
|
38
50
|
workflow: DynamicWorkflowConfig;
|
|
@@ -119,7 +131,7 @@ async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowSc
|
|
|
119
131
|
// DISC-1: acorn can only parse JavaScript, not TypeScript. Real .dwf.ts files
|
|
120
132
|
// contain type annotations/imports that cause a silent parse error, so the
|
|
121
133
|
// determinism check never runs. Transpile to JS via esbuild first.
|
|
122
|
-
const js = transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
|
|
134
|
+
const js = esbuild().transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
|
|
123
135
|
assertDeterministicScript(js);
|
|
124
136
|
}
|
|
125
137
|
// jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
|
|
@@ -29,6 +29,7 @@ import type { TeamConfig } from "../../teams/team-config.ts";
|
|
|
29
29
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
30
30
|
import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
31
31
|
import { resolveCrewRuntime } from "../model/runtime-resolver.ts";
|
|
32
|
+
import { registryFromModelContext } from "../model/session-model.ts";
|
|
32
33
|
import { executeTeamRun } from "../team-runner.ts";
|
|
33
34
|
import { compareSnapshot, snapshotManifests } from "../verification/verification-integrity.ts";
|
|
34
35
|
import { acquireWorkspaceLock, type WorkspaceLockHandle } from "../workspace-lock.ts";
|
|
@@ -65,6 +66,29 @@ export interface RunGoalLoopResult {
|
|
|
65
66
|
goalState: GoalLoopState;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Finding #1 (battery 2026-09-29): re-hydrate the model routing inputs the
|
|
71
|
+
* OUTER goal-loop manifest persisted at dispatch time (captureRunModelContext
|
|
72
|
+
* in goal.ts / goal-wrap.ts). The detached goal-loop runs in the background
|
|
73
|
+
* runner with no ExtensionContext — without this, every turn's executeTeamRun
|
|
74
|
+
* resolves the DEFAULT chain (live: minimax/MiniMax-M3 402 while the parent
|
|
75
|
+
* session ran zai/glm-5.3). Mirrors background-runner's restoredModelRouting().
|
|
76
|
+
*/
|
|
77
|
+
function restoredTurnModelRouting(manifest: TeamRunManifest): {
|
|
78
|
+
modelOverride?: string;
|
|
79
|
+
parentModel?: string;
|
|
80
|
+
modelRegistry?: { getAvailable: () => unknown[] };
|
|
81
|
+
} {
|
|
82
|
+
const context = manifest.modelContext;
|
|
83
|
+
if (!context) return {};
|
|
84
|
+
const modelRegistry = registryFromModelContext(context);
|
|
85
|
+
return {
|
|
86
|
+
...(context.override ? { modelOverride: context.override } : {}),
|
|
87
|
+
...(context.parentModel ? { parentModel: context.parentModel } : {}),
|
|
88
|
+
...(modelRegistry ? { modelRegistry } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
/**
|
|
69
93
|
* The placeholder evaluator for P0: always returns {achieved:false}.
|
|
70
94
|
* Kept for unit tests of the loop's max_turns exit path. The production loop
|
|
@@ -630,6 +654,10 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
630
654
|
workspaceMode: "single",
|
|
631
655
|
ownerSessionId: goal.ownerSessionId,
|
|
632
656
|
runKind: "team-run", // §0a v2 note: turns are normal team-runs; the OUTER loop is goal-loop
|
|
657
|
+
// Finding #1: turns execute in the detached process — carry the outer
|
|
658
|
+
// manifest's model routing snapshot onto each turn manifest too, so
|
|
659
|
+
// status/resume paths see the same routing the turn was dispatched with.
|
|
660
|
+
...(manifest.modelContext ? { modelContext: manifest.modelContext } : {}),
|
|
633
661
|
});
|
|
634
662
|
goal =
|
|
635
663
|
store.patch(
|
|
@@ -689,6 +717,10 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
689
717
|
limits: turnExecutedConfig.limits,
|
|
690
718
|
runtime: turnRuntime,
|
|
691
719
|
runtimeConfig: turnExecutedConfig.runtime,
|
|
720
|
+
// Finding #1: restore the dispatch-time model routing (see
|
|
721
|
+
// restoredTurnModelRouting) — executeTeamRun in THIS process has no
|
|
722
|
+
// ExtensionContext to inherit the session model from.
|
|
723
|
+
...restoredTurnModelRouting(manifest),
|
|
692
724
|
reliability: turnExecutedConfig.reliability,
|
|
693
725
|
workspaceId: goal.ownerSessionId ?? goal.cwd,
|
|
694
726
|
signal,
|
|
@@ -59,9 +59,17 @@ export function deliverGroupJoin(input: {
|
|
|
59
59
|
}): CrewGroupJoinDelivery | undefined {
|
|
60
60
|
if (!shouldGroupJoin(input.mode, input.batch)) return undefined;
|
|
61
61
|
const taskIds = input.batch.map((task) => task.id);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// RR-021 WI-4.3d: first-wins Map — the old taskIds.map(id => allTasks.find())
|
|
63
|
+
// was O(batch×allTasks). NOT a raw input.batch pass-through: callers may
|
|
64
|
+
// hold stale batch snapshots, and the only audited production caller
|
|
65
|
+
// (team-runner closeout) passes fresh references — but the Map keeps the
|
|
66
|
+
// exact `.find` first-match semantics for every caller (incl. tests) while
|
|
67
|
+
// staying O(n+m). Batch order is preserved (map follows taskIds).
|
|
68
|
+
const taskById = new Map<string, TeamTaskState>();
|
|
69
|
+
for (const task of input.allTasks) {
|
|
70
|
+
if (!taskById.has(task.id)) taskById.set(task.id, task);
|
|
71
|
+
}
|
|
72
|
+
const latest = taskIds.map((id) => taskById.get(id)).filter((task): task is TeamTaskState => Boolean(task));
|
|
65
73
|
const completed = statusList(latest, "completed");
|
|
66
74
|
const failed = statusList(latest, "failed");
|
|
67
75
|
const skipped = statusList(latest, "skipped");
|
|
@@ -96,7 +96,7 @@ let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
|
|
|
96
96
|
let experimentalWarned = false;
|
|
97
97
|
function loadLiveSessionModule(): Promise<LiveSessionModule> {
|
|
98
98
|
if (!liveSessionModulePromise) {
|
|
99
|
-
liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
|
|
99
|
+
liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>; // LAZY: defer pi-coding-agent live-session module until first use
|
|
100
100
|
}
|
|
101
101
|
return liveSessionModulePromise;
|
|
102
102
|
}
|
|
@@ -124,6 +124,7 @@ export function startRuntimeWarmup(): void {
|
|
|
124
124
|
for (const spec of HOT_MODULE_SPECIFIERS) {
|
|
125
125
|
imports.push(
|
|
126
126
|
import(new URL(spec, import.meta.url).href).catch(() => {
|
|
127
|
+
// LAZY: best-effort warmup import, failures tolerated
|
|
127
128
|
// swallow — never block registration on a warmup failure
|
|
128
129
|
}),
|
|
129
130
|
);
|
|
@@ -131,6 +132,7 @@ export function startRuntimeWarmup(): void {
|
|
|
131
132
|
for (const dep of HOT_PEER_DEPS) {
|
|
132
133
|
imports.push(
|
|
133
134
|
import(dep).catch(() => {
|
|
135
|
+
// LAZY: best-effort warmup import, failures tolerated
|
|
134
136
|
// peer dep may be absent (optional dep) — swallow
|
|
135
137
|
}),
|
|
136
138
|
);
|
|
@@ -123,10 +123,15 @@ export function validateWorkerOutput(role: string, output: string): OutputValida
|
|
|
123
123
|
*
|
|
124
124
|
* A trimmed non-empty line counts as log noise ONLY if it matches one of:
|
|
125
125
|
* 1. a bracket-tag extension log line: `[oc-go] ...`, `[pi-qwen-mm] ...`,
|
|
126
|
-
* `[pi-qwen-mm] [core] [stderr]
|
|
127
|
-
*
|
|
128
|
-
* `[
|
|
129
|
-
*
|
|
126
|
+
* `[pi-qwen-mm] [core] [stderr] ...`, and — Finding #2 (2026-09-29
|
|
127
|
+
* battery) — colon-bearing extension tags like
|
|
128
|
+
* `[pi-crew:crash-recovery.reconcileStaleRuns] ...` or
|
|
129
|
+
* `[pi-crew:crew-vibes.publish-quota-status] ...`. The leading tag
|
|
130
|
+
* starts with a lowercase char (deliberately excludes capitalized
|
|
131
|
+
* bracketed prose like `[Note] ...` / `[Note: ...]`); the rest of the tag
|
|
132
|
+
* may carry `:`, `-`, `_`, `.`, digits, and inner capitals (extension
|
|
133
|
+
* subsystem names are camelCase, e.g. `reconcileStaleRuns`), optionally
|
|
134
|
+
* followed by known sub-tags (core/mcp/stderr/stdout/warn/info/error/debug);
|
|
130
135
|
* 2. a Python `warnings.warn(` line (deprecation-warning continuation);
|
|
131
136
|
* 3. a timestamped logging line: `2026-08-15 21:49:02,986 WARNING ...`.
|
|
132
137
|
*
|
|
@@ -137,7 +142,7 @@ export function validateWorkerOutput(role: string, output: string): OutputValida
|
|
|
137
142
|
* (post-execution.ts) applies the two-gate rule (authoritative output
|
|
138
143
|
* sources empty AND artifact log-noise-only) before failing a task.
|
|
139
144
|
*/
|
|
140
|
-
const BRACKET_TAG_LOG_LINE = /^\[[a-z0-9][a-
|
|
145
|
+
const BRACKET_TAG_LOG_LINE = /^\[[a-z0-9][a-zA-Z0-9_.:\-]*\](?:\s*\[(?:core|mcp|stderr|stdout|warn|info|error|debug)\])*(?:\s.*)?$/;
|
|
141
146
|
const PYTHON_WARNING_LINE = /(?:^|\s)warnings\.warn\(/;
|
|
142
147
|
const TIMESTAMPED_LOG_LINE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,6})?(?:Z|[+-]\d{2}:?\d{2})?\s+[A-Za-z]+\b/;
|
|
143
148
|
|
|
@@ -70,7 +70,20 @@ export async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tas
|
|
|
70
70
|
const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
|
|
71
71
|
// ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
|
|
72
72
|
// completed read-only (planning) task as the plan reference.
|
|
73
|
-
|
|
73
|
+
// RR-021 WI-4.3e: allocation-free reverse scan — the old
|
|
74
|
+
// [...tasks].reverse().find() copied the whole array per call.
|
|
75
|
+
// (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
|
|
76
|
+
// ambient resolution — see WI-4.3 evidence note.)
|
|
77
|
+
let planTask = assessTask;
|
|
78
|
+
if (!planTask) {
|
|
79
|
+
for (let i = tasks.length - 1; i >= 0; i--) {
|
|
80
|
+
const t = tasks[i];
|
|
81
|
+
if (t?.status === "completed" && !isMutatingTask(t)) {
|
|
82
|
+
planTask = t;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
74
87
|
const now = new Date().toISOString();
|
|
75
88
|
|
|
76
89
|
// T2/R4 (ADR-4 §6 producer 3 + §8 dual-write): make sure a PlanRecord
|
|
@@ -4,10 +4,16 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { DEFAULT_PATHS } from "../../config/defaults.ts";
|
|
5
5
|
import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
|
|
6
6
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
7
|
-
import { discoverRunLockFiles, sweepStaleLocks, withRunLockSync } from "../../state/coordination/locks.ts";
|
|
7
|
+
import { discoverRunLockFiles, sweepStaleLocks, withRunLock, withRunLockSync } from "../../state/coordination/locks.ts";
|
|
8
8
|
import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
|
|
9
9
|
import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
loadRunManifestById,
|
|
12
|
+
loadRunManifestByIdAsync,
|
|
13
|
+
saveRunManifest,
|
|
14
|
+
saveRunTasks,
|
|
15
|
+
updateRunStatus,
|
|
16
|
+
} from "../../state/stores/state-store.ts";
|
|
11
17
|
import type { TeamTaskState } from "../../state/types.ts";
|
|
12
18
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
13
19
|
import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
|
|
@@ -18,6 +24,7 @@ import { isWorkerHeartbeatStale } from "../heartbeat/worker-heartbeat.ts";
|
|
|
18
24
|
import { terminateLiveAgentsForRun } from "../live-session/live-agent-manager.ts";
|
|
19
25
|
import type { ManifestCache } from "../manifest-cache.ts";
|
|
20
26
|
import { checkProcessLiveness } from "../process-status.ts";
|
|
27
|
+
import { mapConcurrent } from "../scheduling/parallel-utils.ts";
|
|
21
28
|
import { isIntentionalWait, isPlanApprovalPendingEffective, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
|
|
22
29
|
|
|
23
30
|
export interface RecoveryPlan {
|
|
@@ -690,13 +697,12 @@ export function purgeStaleActiveRunIndex(
|
|
|
690
697
|
return { purged, kept };
|
|
691
698
|
}
|
|
692
699
|
|
|
693
|
-
export function reconcileAllStaleRuns(
|
|
700
|
+
export async function reconcileAllStaleRuns(
|
|
694
701
|
cwd: string,
|
|
695
702
|
manifestCache: ManifestCache,
|
|
696
703
|
now = Date.now(),
|
|
697
704
|
currentSessionId?: string,
|
|
698
|
-
): ReconcileResult[] {
|
|
699
|
-
const results: ReconcileResult[] = [];
|
|
705
|
+
): Promise<ReconcileResult[]> {
|
|
700
706
|
// Capture runIds to reconcile BEFORE acquiring locks — avoids TOCTOU between cache iteration and lock acquisition.
|
|
701
707
|
const runIds = manifestCache
|
|
702
708
|
.list(50)
|
|
@@ -708,56 +714,73 @@ export function reconcileAllStaleRuns(
|
|
|
708
714
|
return true;
|
|
709
715
|
})
|
|
710
716
|
.map((m) => m.runId);
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
//
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
return;
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
717
|
+
// RR-021 WI-1.5 (audit C3): serial reconcile → mapConcurrent bound 4.
|
|
718
|
+
// Each runId acquires its OWN run lock file (lockPath() is per-run), so the
|
|
719
|
+
// acquisitions are independent; bound 4 caps fs/CPU contention. Results keep
|
|
720
|
+
// input (snapshot) order — mapConcurrent indexes by item — and per-run
|
|
721
|
+
// semantics (re-read inside lock, plan-approval re-check) are unchanged.
|
|
722
|
+
const perRun = await mapConcurrent(runIds, 4, async (runId): Promise<ReconcileResult[]> => {
|
|
723
|
+
// RR-021 review remediation: per-run error isolation — one corrupt run
|
|
724
|
+
// must not reject the whole sweep (serial baseline threw too, but the
|
|
725
|
+
// SPEC's "independent per-run" intent requires continue-on-error).
|
|
726
|
+
try {
|
|
727
|
+
const cached = manifestCache.get(runId);
|
|
728
|
+
if (!cached) return [];
|
|
729
|
+
const loaded = await loadRunManifestByIdAsync(cwd, runId); // NOTE: best-effort only; concurrent writes may cause inconsistency
|
|
730
|
+
if (!loaded) return [];
|
|
731
|
+
const out: ReconcileResult[] = [];
|
|
732
|
+
// Use lock to prevent race with cancel/status handlers modifying the same run.
|
|
733
|
+
// Same v0.9.26 lock family as the old sync acquisition — interop-safe.
|
|
734
|
+
await withRunLock(loaded.manifest, async () => {
|
|
735
|
+
// Re-read inside lock to get freshest data
|
|
736
|
+
const fresh = await loadRunManifestByIdAsync(cwd, runId); // NOTE: inside withRunLock - consistent read
|
|
737
|
+
if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
|
|
738
|
+
// Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
|
|
739
|
+
// may have flipped to blocked+plan-approval between cache-list and lock
|
|
740
|
+
// acquisition — re-check the freshest manifest under the lock.
|
|
741
|
+
if (isPlanApprovalPendingEffective(fresh.manifest)) {
|
|
742
|
+
out.push({
|
|
743
|
+
runId,
|
|
744
|
+
verdict: "blocked_awaiting_approval",
|
|
745
|
+
repaired: false,
|
|
746
|
+
detail: "Plan approval is pending; stale reconciliation skipped",
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
|
|
751
|
+
if (result.repaired || result.verdict === "result_exists") {
|
|
752
|
+
if (result.repairedTasks) {
|
|
753
|
+
saveRunTasks(fresh.manifest, result.repairedTasks);
|
|
754
|
+
for (const task of result.repairedTasks) {
|
|
755
|
+
try {
|
|
756
|
+
upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
|
|
757
|
+
} catch {
|
|
758
|
+
/* non-critical */
|
|
759
|
+
}
|
|
742
760
|
}
|
|
743
761
|
}
|
|
762
|
+
updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
|
|
763
|
+
void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) =>
|
|
764
|
+
logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn"),
|
|
765
|
+
);
|
|
766
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
767
|
+
type: "crew.run.reconciled_stale",
|
|
768
|
+
runId,
|
|
769
|
+
message: result.detail,
|
|
770
|
+
data: { verdict: result.verdict },
|
|
771
|
+
});
|
|
744
772
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (result.verdict !== "healthy") {
|
|
757
|
-
results.push(result);
|
|
758
|
-
}
|
|
759
|
-
});
|
|
760
|
-
}
|
|
773
|
+
if (result.verdict !== "healthy") {
|
|
774
|
+
out.push(result);
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
return out;
|
|
778
|
+
} catch (error) {
|
|
779
|
+
logInternalError("crash-recovery.reconcileStaleRuns", error, `runId=${runId}`, "warn");
|
|
780
|
+
return [];
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
const results = perRun.flat();
|
|
761
784
|
// US-002 (2026-09-22): structured stale-lock sweep. Runs after reconciliation
|
|
762
785
|
// so locks whose holder died (kill -9 / crash) do not linger. The sweep is
|
|
763
786
|
// strictly safer than acquire-time steal (stale AND holder-dead only), so a
|
|
@@ -342,16 +342,43 @@ export class SubagentManager {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
async waitForRecord(id: string): Promise<SubagentRecord | undefined> {
|
|
345
|
+
async waitForRecord(id: string, timeoutMs = 300_000): Promise<SubagentRecord | undefined> {
|
|
346
|
+
// RR-021 WI-4.3i: bounded wait. A record with no promise whose status
|
|
347
|
+
// stays running/blocked used to spin forever (100ms sleep loop). On
|
|
348
|
+
// deadline expiry we return the CURRENT record — never undefined, which
|
|
349
|
+
// would be ambiguous with 'no such record'. Callers can inspect
|
|
350
|
+
// record.status to see the wait timed out on a still-active record.
|
|
351
|
+
const deadline = Date.now() + timeoutMs;
|
|
346
352
|
while (true) {
|
|
347
353
|
const record = this.records.get(id);
|
|
348
354
|
if (!record) return undefined;
|
|
349
355
|
if (record.status !== "running" && record.status !== "queued") return record;
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
356
|
+
if (Date.now() > deadline) return record;
|
|
357
|
+
if (record.promise) {
|
|
358
|
+
// Race the run promise against the remaining deadline — a wedged
|
|
359
|
+
// promise must not block the deadline check.
|
|
360
|
+
const remaining = deadline - Date.now();
|
|
361
|
+
let timer: NodeJS.Timeout | undefined;
|
|
362
|
+
try {
|
|
363
|
+
await Promise.race([
|
|
364
|
+
record.promise.catch((error) => {
|
|
365
|
+
logInternalError("subagent-manager.waitForRecord", error, `id=${id}`);
|
|
366
|
+
}),
|
|
367
|
+
// RR-021 review remediation: this timer MUST stay ref'd. unref() let the
|
|
368
|
+
// event loop drain before the deadline fired (node:test then cancels the
|
|
369
|
+
// awaiting test — "Promise resolution is still pending but the event loop
|
|
370
|
+
// has already resolved"). Always clearTimeout'd in the finally below, so
|
|
371
|
+
// ref'ing cannot leak (knowledge.md OwnedProcess timer guidance).
|
|
372
|
+
new Promise<void>((resolve) => {
|
|
373
|
+
timer = setTimeout(resolve, remaining);
|
|
374
|
+
}),
|
|
375
|
+
]);
|
|
376
|
+
} finally {
|
|
377
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(1, deadline - Date.now()))));
|
|
381
|
+
}
|
|
355
382
|
}
|
|
356
383
|
}
|
|
357
384
|
|
|
@@ -477,19 +504,22 @@ export class SubagentManager {
|
|
|
477
504
|
}
|
|
478
505
|
|
|
479
506
|
private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
|
|
480
|
-
// Safety: max 30 minutes
|
|
481
|
-
// polling if manifest file is deleted or run state becomes unrecoverable.
|
|
482
|
-
|
|
483
|
-
|
|
507
|
+
// Safety: max 30 minutes of WALL-CLOCK polling to prevent infinite
|
|
508
|
+
// polling if the manifest file is deleted or run state becomes unrecoverable.
|
|
509
|
+
// (RR-021 WI-4.3h: was MAX_POLL_COUNT=1800, which silently assumed a 1s
|
|
510
|
+
// pollIntervalMs — but the interval is configurable, so a 5s interval
|
|
511
|
+
// polled for 2.5h while a 200ms interval timed out after 6 minutes.)
|
|
512
|
+
const POLL_DEADLINE_MS = 30 * 60 * 1000;
|
|
513
|
+
const pollDeadline = Date.now() + POLL_DEADLINE_MS;
|
|
484
514
|
while (record.runId && (record.status === "running" || record.status === "blocked")) {
|
|
485
|
-
if (
|
|
515
|
+
if (Date.now() > pollDeadline) {
|
|
486
516
|
logInternalError(
|
|
487
517
|
"subagent-manager.poll-timeout",
|
|
488
|
-
new Error(`pollRunToTerminal exceeded ${
|
|
518
|
+
new Error(`pollRunToTerminal exceeded ${POLL_DEADLINE_MS}ms wall-clock for runId=${record.runId}`),
|
|
489
519
|
`id=${record.id}`,
|
|
490
520
|
);
|
|
491
521
|
record.status = "error";
|
|
492
|
-
record.error = `Poll timeout: run did not reach terminal state after ${
|
|
522
|
+
record.error = `Poll timeout: run did not reach terminal state after ${POLL_DEADLINE_MS}ms (wall-clock)`;
|
|
493
523
|
record.completedAt = Date.now();
|
|
494
524
|
savePersistedSubagentRecord(cwd, record);
|
|
495
525
|
return;
|
|
@@ -68,7 +68,7 @@ import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
|
|
|
68
68
|
import { runWorker } from "../run-worker.ts";
|
|
69
69
|
import { parseSessionUsage } from "../session-usage.ts";
|
|
70
70
|
import { recordSupervisorContact, supervisorContactFromEvent } from "../supervisor-contact.ts";
|
|
71
|
-
import type { TaskExecutionResult } from "./post-execution.ts";
|
|
71
|
+
import type { ResultSource, TaskExecutionResult } from "./post-execution.ts";
|
|
72
72
|
import type { StreamBridgeHandle, TaskExecutionContext } from "./pre-execution.ts";
|
|
73
73
|
import { applyAgentProgressEvent, applyUsageToProgress, progressEventSummary, shouldFlushProgressEvent } from "./progress.ts";
|
|
74
74
|
import { cleanResultText, isFinalChildEvent } from "./result-utils.ts";
|
|
@@ -987,6 +987,20 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
987
987
|
});
|
|
988
988
|
}
|
|
989
989
|
}
|
|
990
|
+
// Finding #2 (2026-09-29 battery): tag WHICH fallback branch produced the
|
|
991
|
+
// artifact so post-execution can fail stderr/none-sourced results
|
|
992
|
+
// deterministically — the noise classifier alone is not a verdict oracle.
|
|
993
|
+
// Order/semantics are identical to the previous `??` chain: first branch
|
|
994
|
+
// whose cleaned text is non-empty wins; otherwise the placeholder ("none").
|
|
995
|
+
const resultCandidates: ReadonlyArray<{ source: ResultSource; text: string | undefined }> = [
|
|
996
|
+
{ source: "rawFinalText", text: cleanResultText(rawFinalText) },
|
|
997
|
+
{ source: "finalText", text: cleanResultText(parsedOutput?.finalText) },
|
|
998
|
+
{ source: "stdout", text: cleanResultText(finalStdout) },
|
|
999
|
+
{ source: "stderr", text: cleanResultText(finalStderr) },
|
|
1000
|
+
{ source: "findings", text: cleanResultText(intermediateFindings) },
|
|
1001
|
+
];
|
|
1002
|
+
const usedResult = resultCandidates.find((candidate) => candidate.text);
|
|
1003
|
+
const resultSource: ResultSource = usedResult?.source ?? "none";
|
|
990
1004
|
const resultArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
991
1005
|
kind: "result",
|
|
992
1006
|
relativePath: `results/${task.id}.txt`,
|
|
@@ -995,14 +1009,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
995
1009
|
// transcript's 16K compaction — this is the authoritative worker output.
|
|
996
1010
|
// Fall back to transcript-derived finalText, then stdout/stderr, so a
|
|
997
1011
|
// missing raw capture (mock/error path) never yields empty/garbage.
|
|
998
|
-
|
|
999
|
-
cleanResultText(parsedOutput?.finalText) ??
|
|
1000
|
-
cleanResultText(finalStdout) ??
|
|
1001
|
-
cleanResultText(finalStderr) ??
|
|
1012
|
+
usedResult?.text ??
|
|
1002
1013
|
// #7 hardening: if all real output paths are empty (worker exhausted
|
|
1003
1014
|
// budget on tool calls, no assistant text), use intermediate findings.
|
|
1004
1015
|
// intermediateFindings captures the last N tool-result display lines.
|
|
1005
|
-
cleanResultText(intermediateFindings) ??
|
|
1006
1016
|
"(no output)",
|
|
1007
1017
|
producer: task.id,
|
|
1008
1018
|
});
|
|
@@ -1094,6 +1104,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
1094
1104
|
parsedOutput,
|
|
1095
1105
|
finalStdout,
|
|
1096
1106
|
rawFinalText,
|
|
1107
|
+
resultSource,
|
|
1097
1108
|
transcriptPath,
|
|
1098
1109
|
terminalEvidence,
|
|
1099
1110
|
startupEvidence,
|