pi-crew 0.9.68 → 0.10.2
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 +222 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +68113 -60774
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +503 -91
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +687 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +43 -2
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +11 -9
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +49 -1
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +228 -0
- package/src/extension/registration/commands/shared.ts +639 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +178 -17
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +35 -3
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +74 -20
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +32 -10
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +36 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +111 -46
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +132 -1673
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +66 -26
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +139 -232
- package/src/state/types.ts +199 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +243 -38
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +20 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +17 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-approval gate helpers.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
|
|
5
|
+
* plan-approval family). Pure code motion: requiresPlanApproval /
|
|
6
|
+
* isPlanApprovalPending / isMutatingTask / ensurePlanApprovalRequested moved
|
|
7
|
+
* verbatim from team-runner.ts. cancelPlanTasks stays in team-runner.ts
|
|
8
|
+
* (RT-14 structural source-pin test requires it defined there).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import type { CrewRuntimeConfig } from "../config/config.ts";
|
|
13
|
+
import { parsePlannerPlanOutput } from "../extension/plan-orchestrate.ts";
|
|
14
|
+
import { appendEvent } from "../state/event-log/event-log.ts";
|
|
15
|
+
import { appendPlanRevision, getCurrentPlanRecord, setPlanApproval } from "../state/stores/plan-store.ts";
|
|
16
|
+
import { saveRunManifestAsync } from "../state/stores/state-store.ts";
|
|
17
|
+
import type { PlanRecord, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
18
|
+
import type { WorkflowConfig } from "../workflows/workflow-config.ts";
|
|
19
|
+
import { permissionForRole } from "./role-permission.ts";
|
|
20
|
+
|
|
21
|
+
export function requiresPlanApproval(_workflow: WorkflowConfig, runtimeConfig: CrewRuntimeConfig | undefined): boolean {
|
|
22
|
+
// ROADMAP T1.2: plan-level HITL applies to ANY workflow when
|
|
23
|
+
// config.runtime.requirePlanApproval === true (not just 'implementation').
|
|
24
|
+
// The gate fires at the read-only → mutating (plan → execute) boundary.
|
|
25
|
+
return runtimeConfig?.requirePlanApproval === true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** State-level predicate shared by every render surface (widget badge,
|
|
29
|
+
* progress banner, powerbar segment) so gating is byte-identical to the
|
|
30
|
+
* action side: `required === true && status === "pending"`. A malformed
|
|
31
|
+
* manifest (`pending` without `required`) must NOT light up surfaces while
|
|
32
|
+
* keys/backends refuse — single source of truth (WP-3 review F2). */
|
|
33
|
+
export function isPlanApprovalStatePending(approval: TeamRunManifest["planApproval"]): boolean {
|
|
34
|
+
return approval?.required === true && approval.status === "pending";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
|
|
38
|
+
return isPlanApprovalStatePending(manifest.planApproval);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* T2/R4 (ADR-4 §2, reader migration): plan-record-first approval predicate
|
|
43
|
+
* with manifest fallback. The current revision's approval state, when present,
|
|
44
|
+
* is authoritative; otherwise the legacy manifest gate decides. Pre-v2 runs
|
|
45
|
+
* (no plans.json) take exactly the old path — pinned by the migration
|
|
46
|
+
* negative AC in plan-store.test.ts.
|
|
47
|
+
*/
|
|
48
|
+
export function isPlanApprovalPendingEffective(manifest: TeamRunManifest): boolean {
|
|
49
|
+
const current = getCurrentPlanRecord(manifest);
|
|
50
|
+
if (current?.approval) return current.approval.status === "pending";
|
|
51
|
+
return isPlanApprovalPending(manifest);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* T2/R4 (ADR-4 §8 vocabulary mapping): a deny is `cancelled` on the manifest
|
|
56
|
+
* side and `rejected` on the record side — either means "decided negative"
|
|
57
|
+
* to the scheduler cancel paths (team-runner.ts readers).
|
|
58
|
+
*/
|
|
59
|
+
export function isPlanApprovalDenied(manifest: TeamRunManifest): boolean {
|
|
60
|
+
if (manifest.planApproval?.status === "cancelled") return true;
|
|
61
|
+
return getCurrentPlanRecord(manifest)?.approval?.status === "rejected";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isMutatingTask(task: TeamTaskState): boolean {
|
|
65
|
+
return permissionForRole(task.role) !== "read_only";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<TeamRunManifest> {
|
|
69
|
+
if (manifest.planApproval) return manifest;
|
|
70
|
+
const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
|
|
71
|
+
// ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
|
|
72
|
+
// completed read-only (planning) task as the plan reference.
|
|
73
|
+
const planTask = assessTask ?? [...tasks].reverse().find((t) => t.status === "completed" && !isMutatingTask(t));
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
|
|
76
|
+
// T2/R4 (ADR-4 §6 producer 3 + §8 dual-write): make sure a PlanRecord
|
|
77
|
+
// exists before the gate lights up, so approval names plan id+version.
|
|
78
|
+
// - Record already present (adaptive producer ran, or a re-request after
|
|
79
|
+
// crash between the record append and the manifest save) → reuse it.
|
|
80
|
+
// - No record → try the planner-tagged `<plan>` contract on the plan
|
|
81
|
+
// artifact; if that fails → manifest-only gate (pre-v2 behavior, the
|
|
82
|
+
// dual-read fallback in plan-store keeps readers correct).
|
|
83
|
+
let record = getCurrentPlanRecord(manifest);
|
|
84
|
+
if (!record && planTask?.resultArtifact?.path) {
|
|
85
|
+
try {
|
|
86
|
+
const text = fs.readFileSync(planTask.resultArtifact.path, "utf-8");
|
|
87
|
+
const parsed: PlanRecord | undefined = parsePlannerPlanOutput(text, manifest.runId, planTask.id);
|
|
88
|
+
if (parsed) record = appendPlanRevision(manifest, parsed);
|
|
89
|
+
} catch {
|
|
90
|
+
// Unreadable/unparsable artifact → manifest-only gate (below); the
|
|
91
|
+
// gate must still light up — never a throw here.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (record) {
|
|
95
|
+
setPlanApproval(manifest, { status: "pending", planVersion: record.version });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const updated: TeamRunManifest = {
|
|
99
|
+
...manifest,
|
|
100
|
+
updatedAt: now,
|
|
101
|
+
planApproval: {
|
|
102
|
+
required: true,
|
|
103
|
+
status: "pending",
|
|
104
|
+
requestedAt: now,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
planTaskId: planTask?.id,
|
|
107
|
+
planArtifactPath: planTask?.resultArtifact?.path,
|
|
108
|
+
},
|
|
109
|
+
plan: record ? { id: record.id, version: record.version } : manifest.plan,
|
|
110
|
+
};
|
|
111
|
+
await saveRunManifestAsync(updated);
|
|
112
|
+
appendEvent(updated.eventsPath, {
|
|
113
|
+
type: "plan.approval_required",
|
|
114
|
+
runId: updated.runId,
|
|
115
|
+
taskId: planTask?.id,
|
|
116
|
+
message: "Plan requires explicit approval before mutating tasks run. Use: team api op=approve-plan runId=...",
|
|
117
|
+
data: { planArtifactPath: planTask?.resultArtifact?.path, planId: record?.id, planVersion: record?.version },
|
|
118
|
+
});
|
|
119
|
+
return updated;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @internal 1.9(b) test export — exercise requiresPlanApproval directly. */
|
|
123
|
+
export const __test__requiresPlanApproval = requiresPlanApproval;
|
|
124
|
+
/** @internal 1.9(b) test export — exercise ensurePlanApprovalRequested directly. */
|
|
125
|
+
export const __test__ensurePlanApprovalRequested = ensurePlanApprovalRequested;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T2/R4 (ADR-4 §4) — re-plan sweep: dropped-item soft-cancel.
|
|
3
|
+
*
|
|
4
|
+
* Every scheduler tick (selectDispatchBatch, after the ask-deadline sweep)
|
|
5
|
+
* checks the CURRENT plan revision for `dropped` items and reconciles their
|
|
6
|
+
* tasks:
|
|
7
|
+
* - QUEUED tasks are cancelled outright (never dispatched — the re-plan
|
|
8
|
+
* removed their reason to exist).
|
|
9
|
+
* - RUNNING / WAITING / NEEDS_ATTENTION tasks get a SOFT cancel: a wrap-up
|
|
10
|
+
* advisory appended to the worker's steering file (the same JSONL contract
|
|
11
|
+
* as `team steer` and the ChildPiSteeringController soft limit — advisory
|
|
12
|
+
* first, the existing maxTurns+grace machinery stays the hard enforcement).
|
|
13
|
+
* `task.replanDroppedAt` stamps the advisory as issued (exactly-once across
|
|
14
|
+
* ticks) and doubles as the terminal marker "cancelled-by-replan".
|
|
15
|
+
* - A `plan.item.dropped` event is appended per affected task.
|
|
16
|
+
*
|
|
17
|
+
* Reload+persist discipline mirrors sweepExpiredWaitingTasks: best-effort
|
|
18
|
+
* reload, all mutations under withRunLockSync with a fresh in-lock reload,
|
|
19
|
+
* save only when something changed. Never throws into the scheduler tick —
|
|
20
|
+
* failures are logged and the sweep returns undefined (next tick retries).
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { withRunLockSync } from "../state/coordination/locks.ts";
|
|
25
|
+
import { appendEvent } from "../state/event-log/event-log.ts";
|
|
26
|
+
import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
|
|
27
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../state/stores/state-store.ts";
|
|
28
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
29
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
30
|
+
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
31
|
+
|
|
32
|
+
export interface DroppedPlanSweepResult {
|
|
33
|
+
manifest: TeamRunManifest;
|
|
34
|
+
tasks: TeamTaskState[];
|
|
35
|
+
cancelledTaskIds: string[];
|
|
36
|
+
advisedTaskIds: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const WRAP_UP_ADVISORY =
|
|
40
|
+
"Plan item dropped by a newer plan revision (re-plan). Wrap up immediately — provide your best final answer now; this task will not be continued.";
|
|
41
|
+
|
|
42
|
+
function appendSteeringAdvisory(manifest: TeamRunManifest, taskId: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
const steeringDir = path.join(manifest.artifactsRoot, "steering");
|
|
45
|
+
fs.mkdirSync(steeringDir, { recursive: true });
|
|
46
|
+
const safePath = resolveRealContainedPath(steeringDir, `${taskId}.jsonl`);
|
|
47
|
+
// Same 256KB growth cap as the steer tool (subagent-tools.ts) — refuse
|
|
48
|
+
// loudly rather than poisoning the next incarnation's first poll.
|
|
49
|
+
try {
|
|
50
|
+
if (fs.statSync(safePath).size > 256 * 1024) {
|
|
51
|
+
logInternalError("plan-replan.steer-refused-too-large", new Error("steering file over cap"), `taskId=${taskId}`);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
/* no file yet — fine */
|
|
56
|
+
}
|
|
57
|
+
fs.appendFileSync(
|
|
58
|
+
safePath,
|
|
59
|
+
`${JSON.stringify({ type: "steer", message: WRAP_UP_ADVISORY, ts: new Date().toISOString() })}\n`,
|
|
60
|
+
"utf-8",
|
|
61
|
+
);
|
|
62
|
+
return true;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logInternalError("plan-replan.steer-write-failed", error instanceof Error ? error : new Error(String(error)), `taskId=${taskId}`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Takes the scheduler's IN-MEMORY manifest+tasks (selectDispatchBatch already
|
|
71
|
+
* holds both) — NO disk load on the no-op path. The per-tick cost for runs
|
|
72
|
+
* without plan-linked tasks is one array scan; plans.json is only read when a
|
|
73
|
+
* task carries planItem, and the reload+persist below only runs when an
|
|
74
|
+
* affected task actually exists.
|
|
75
|
+
*/
|
|
76
|
+
export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialTasks: TeamTaskState[]): DroppedPlanSweepResult | undefined {
|
|
77
|
+
// Cheap exits FIRST (no I/O): no plan-linked tasks at all.
|
|
78
|
+
if (!initialTasks.some((t) => t.planItem)) return undefined;
|
|
79
|
+
const record = getCurrentPlanRecord(initialManifest);
|
|
80
|
+
if (!record) return undefined;
|
|
81
|
+
const dropped = new Set(record.items.filter((i) => i.status === "dropped").map((i) => i.id));
|
|
82
|
+
if (dropped.size === 0) return undefined;
|
|
83
|
+
if (!initialTasks.some((t) => t.planItem && dropped.has(t.planItem))) return undefined;
|
|
84
|
+
|
|
85
|
+
const cwd = initialManifest.cwd;
|
|
86
|
+
const runId = initialManifest.runId;
|
|
87
|
+
const cancelledTaskIds: string[] = [];
|
|
88
|
+
const advisedTaskIds: string[] = [];
|
|
89
|
+
try {
|
|
90
|
+
const outcome = withRunLockSync(initialManifest, () => {
|
|
91
|
+
const fresh = loadRunManifestById(cwd, runId); // in-lock consistent read
|
|
92
|
+
if (!fresh) return null;
|
|
93
|
+
const freshRecord = getCurrentPlanRecord(fresh.manifest);
|
|
94
|
+
if (!freshRecord) return null;
|
|
95
|
+
const freshDropped = new Set(freshRecord.items.filter((i) => i.status === "dropped").map((i) => i.id));
|
|
96
|
+
if (freshDropped.size === 0) return null;
|
|
97
|
+
let tasks = fresh.tasks;
|
|
98
|
+
let changed = false;
|
|
99
|
+
for (const task of fresh.tasks) {
|
|
100
|
+
if (!task.planItem || !freshDropped.has(task.planItem)) continue;
|
|
101
|
+
if (task.status === "queued") {
|
|
102
|
+
// Never dispatched — cancel outright.
|
|
103
|
+
tasks = tasks.map((t) =>
|
|
104
|
+
t.id === task.id
|
|
105
|
+
? {
|
|
106
|
+
...t,
|
|
107
|
+
status: "cancelled" as const,
|
|
108
|
+
finishedAt: new Date().toISOString(),
|
|
109
|
+
error: "Plan item dropped by re-plan.",
|
|
110
|
+
}
|
|
111
|
+
: t,
|
|
112
|
+
);
|
|
113
|
+
cancelledTaskIds.push(task.id);
|
|
114
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
115
|
+
type: "plan.item.dropped",
|
|
116
|
+
runId,
|
|
117
|
+
taskId: task.id,
|
|
118
|
+
message: `Task ${task.id} cancelled: plan item '${task.planItem}' dropped by re-plan v${freshRecord.version}.`,
|
|
119
|
+
data: { itemId: task.planItem, planId: freshRecord.id, planVersion: freshRecord.version },
|
|
120
|
+
});
|
|
121
|
+
changed = true;
|
|
122
|
+
} else if (
|
|
123
|
+
(task.status === "running" || task.status === "waiting" || task.status === "needs_attention") &&
|
|
124
|
+
!task.replanDroppedAt
|
|
125
|
+
) {
|
|
126
|
+
// In flight — advisory once; maxTurns+grace stays the hard stop.
|
|
127
|
+
if (!appendSteeringAdvisory(fresh.manifest, task.id)) continue;
|
|
128
|
+
tasks = tasks.map((t) => (t.id === task.id ? { ...t, replanDroppedAt: new Date().toISOString() } : t));
|
|
129
|
+
advisedTaskIds.push(task.id);
|
|
130
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
131
|
+
type: "plan.item.dropped",
|
|
132
|
+
runId,
|
|
133
|
+
taskId: task.id,
|
|
134
|
+
message: `Wrap-up advisory delivered to ${task.id}: plan item '${task.planItem}' dropped by re-plan v${freshRecord.version}.`,
|
|
135
|
+
data: { itemId: task.planItem, planId: freshRecord.id, planVersion: freshRecord.version, softCancel: true },
|
|
136
|
+
});
|
|
137
|
+
changed = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!changed) return { manifest: fresh.manifest, tasks: fresh.tasks, cancelledTaskIds, advisedTaskIds };
|
|
141
|
+
saveRunTasks(fresh.manifest, tasks);
|
|
142
|
+
const manifest = { ...fresh.manifest, updatedAt: new Date().toISOString() };
|
|
143
|
+
saveRunManifest(manifest);
|
|
144
|
+
return { manifest, tasks, cancelledTaskIds, advisedTaskIds };
|
|
145
|
+
});
|
|
146
|
+
return outcome ?? undefined;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logInternalError("plan-replan.sweep-failed", error instanceof Error ? error : new Error(String(error)), `runId=${runId}`);
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -25,6 +25,12 @@ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
|
|
|
25
25
|
/** Maximum age (ms) for an active run before it's considered stale.
|
|
26
26
|
* After this time, PID-only liveness is unreliable due to PID recycling. */
|
|
27
27
|
const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
|
|
28
|
+
/** Window (ms) during which an active run whose agents are all
|
|
29
|
+
* completed/queued-without-evidence still counts as display-active: the
|
|
30
|
+
* runner just wrote the manifest (task transition) and the next worker has
|
|
31
|
+
* not recorded progress yet. Without this the dock flickers off between
|
|
32
|
+
* phases of a healthy run. */
|
|
33
|
+
const SPAWN_GAP_VISIBILITY_MS = 60 * 1000;
|
|
28
34
|
|
|
29
35
|
export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
30
36
|
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
|
|
@@ -150,5 +156,14 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
|
|
|
150
156
|
// runs, or from cross-cwd registry history; showing them causes noisy 0/0 rows and
|
|
151
157
|
// needless spinner redraws. The full dashboard can still list historical runs.
|
|
152
158
|
if (agents.length === 0) return false;
|
|
153
|
-
|
|
159
|
+
if (agents.some(hasDurableActiveAgentEvidence)) return true;
|
|
160
|
+
// Spawn gap between phases: an active run whose manifest was JUST written
|
|
161
|
+
// and whose next agents are queued/waiting without evidence yet stays
|
|
162
|
+
// visible — the runner is alive and about to spawn them. Stale manifests
|
|
163
|
+
// (zombies) fall through and stay hidden.
|
|
164
|
+
if (agents.some((agent) => agent.status === "queued" || agent.status === "waiting")) {
|
|
165
|
+
const updatedAt = new Date(run.updatedAt).getTime();
|
|
166
|
+
if (Number.isFinite(updatedAt) && now - updatedAt < SPAWN_GAP_VISIBILITY_MS) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
154
169
|
}
|
|
@@ -166,13 +166,6 @@ export function getCheckpointStore(stateRoot: string): CheckpointStore {
|
|
|
166
166
|
return _stores.get(stateRoot)!;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
/**
|
|
170
|
-
* Clear all checkpoint stores (for testing).
|
|
171
|
-
*/
|
|
172
|
-
export function clearCheckpointStores(): void {
|
|
173
|
-
_stores.clear();
|
|
174
|
-
}
|
|
175
|
-
|
|
176
169
|
/**
|
|
177
170
|
* Save a checkpoint during agent execution.
|
|
178
171
|
*/
|
|
@@ -219,17 +212,6 @@ export function loadCheckpoint(runId: string, taskId: string, cwd?: string): Che
|
|
|
219
212
|
return store.load(runId, taskId);
|
|
220
213
|
}
|
|
221
214
|
|
|
222
|
-
/**
|
|
223
|
-
* Delete a checkpoint after successful completion.
|
|
224
|
-
*/
|
|
225
|
-
export function clearCheckpoint(runId: string, taskId: string, cwd?: string): void {
|
|
226
|
-
assertSafePathId("runId", runId);
|
|
227
|
-
assertSafePathId("taskId", taskId);
|
|
228
|
-
const stateRoot = path.join(projectCrewRoot(cwd ?? process.cwd()), "state", "runs", runId);
|
|
229
|
-
const store = getCheckpointStore(stateRoot);
|
|
230
|
-
store.delete(runId, taskId);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
215
|
/**
|
|
234
216
|
* Check if a checkpoint exists for a task.
|
|
235
217
|
*/
|
|
@@ -6,7 +6,7 @@ import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
|
6
6
|
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
7
7
|
import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
|
|
8
8
|
import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
|
|
9
|
-
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
9
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
10
10
|
import type { TeamTaskState } from "../../state/types.ts";
|
|
11
11
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
12
12
|
import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
|
|
@@ -17,7 +17,7 @@ import { isWorkerHeartbeatStale } from "../heartbeat/worker-heartbeat.ts";
|
|
|
17
17
|
import { terminateLiveAgentsForRun } from "../live-session/live-agent-manager.ts";
|
|
18
18
|
import type { ManifestCache } from "../manifest-cache.ts";
|
|
19
19
|
import { checkProcessLiveness } from "../process-status.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { isIntentionalWait, isPlanApprovalPendingEffective, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
|
|
21
21
|
|
|
22
22
|
export interface RecoveryPlan {
|
|
23
23
|
runId: string;
|
|
@@ -114,7 +114,10 @@ export function detectInterruptedRuns(
|
|
|
114
114
|
for (const manifest of manifestCache.list(50)) {
|
|
115
115
|
if (manifest.status !== "running" && manifest.status !== "blocked") continue;
|
|
116
116
|
// Preserve runs intentionally blocked on plan approval — not crashes.
|
|
117
|
-
|
|
117
|
+
// B1 battery 2026-08-18 (case b): generalized to isIntentionalWait — a run
|
|
118
|
+
// parked on an ask answer is equally intentional and must not be auto-resumed
|
|
119
|
+
// out from under its parked worker.
|
|
120
|
+
if (isIntentionalWait(manifest)) continue;
|
|
118
121
|
if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) continue;
|
|
119
122
|
// Skip runs owned by the current live session — a live session B must NOT
|
|
120
123
|
// detect session A's still-running run as interrupted.
|
|
@@ -142,54 +145,87 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
|
|
|
142
145
|
runId: plan.runId,
|
|
143
146
|
cwd: ctx.cwd,
|
|
144
147
|
});
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
|
|
149
|
+
// R14-4: the hook await above is an arbitrary async gap — a concurrent writer
|
|
150
|
+
// may have completed/cancelled the run in the meantime. Re-read inside the
|
|
151
|
+
// lock and derive every write from the FRESH snapshot; never reset a run that
|
|
152
|
+
// reached a terminal status.
|
|
153
|
+
withRunLockSync(loaded.manifest, () => {
|
|
154
|
+
const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
|
|
155
|
+
if (!fresh) throw new Error(`Run '${plan.runId}' not found.`);
|
|
156
|
+
if (fresh.manifest.status === "completed" || fresh.manifest.status === "failed" || fresh.manifest.status === "cancelled") {
|
|
157
|
+
// Run reached a terminal status while the recovery hook was running —
|
|
158
|
+
// do NOT reset it (no task reset, no status change).
|
|
159
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
160
|
+
type: "crew.run.recovery_skipped",
|
|
161
|
+
runId: plan.runId,
|
|
162
|
+
message: `Recovery skipped: run is already '${fresh.manifest.status}'`,
|
|
163
|
+
data: { status: fresh.manifest.status },
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
appendHookEvent(fresh.manifest, hookReport);
|
|
168
|
+
if (hookReport.outcome === "block") {
|
|
169
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
170
|
+
type: "crew.run.recovery_blocked",
|
|
171
|
+
runId: plan.runId,
|
|
172
|
+
message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
|
|
173
|
+
data: { hookOutcome: "block", reason: hookReport.reason },
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const reset = new Set(plan.resumableTasks);
|
|
179
|
+
const tasks = fresh.tasks.map((task) =>
|
|
180
|
+
reset.has(task.id)
|
|
181
|
+
? {
|
|
182
|
+
...task,
|
|
183
|
+
status: "queued" as const,
|
|
184
|
+
startedAt: undefined,
|
|
185
|
+
finishedAt: undefined,
|
|
186
|
+
error: undefined,
|
|
187
|
+
heartbeat: undefined,
|
|
188
|
+
// WP-2/R2 (ADR-0 item 9): a requeued task must not resume parked
|
|
189
|
+
// state — drop any stale ask-park marker along with the other
|
|
190
|
+
// per-attempt transient fields. Tasks NOT reset (e.g. a task parked
|
|
191
|
+
// in `waiting`) pass through untouched, marker intact; the marker's
|
|
192
|
+
// deadline is then owned by the scheduler tick (dispatch-batch).
|
|
193
|
+
waiting: undefined,
|
|
194
|
+
}
|
|
195
|
+
: task,
|
|
196
|
+
);
|
|
197
|
+
saveRunTasks(fresh.manifest, tasks);
|
|
198
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
199
|
+
type: "crew.run.resumed",
|
|
149
200
|
runId: plan.runId,
|
|
150
|
-
message: `
|
|
151
|
-
data: {
|
|
201
|
+
message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
|
|
202
|
+
data: {
|
|
203
|
+
recoveredFromSeq: plan.lastEventSeq,
|
|
204
|
+
resumableTasks: plan.resumableTasks,
|
|
205
|
+
},
|
|
152
206
|
});
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const reset = new Set(plan.resumableTasks);
|
|
157
|
-
const tasks = loaded.tasks.map((task) =>
|
|
158
|
-
reset.has(task.id)
|
|
159
|
-
? {
|
|
160
|
-
...task,
|
|
161
|
-
status: "queued" as const,
|
|
162
|
-
startedAt: undefined,
|
|
163
|
-
finishedAt: undefined,
|
|
164
|
-
error: undefined,
|
|
165
|
-
heartbeat: undefined,
|
|
166
|
-
}
|
|
167
|
-
: task,
|
|
168
|
-
);
|
|
169
|
-
saveRunTasks(loaded.manifest, tasks);
|
|
170
|
-
appendEvent(loaded.manifest.eventsPath, {
|
|
171
|
-
type: "crew.run.resumed",
|
|
172
|
-
runId: plan.runId,
|
|
173
|
-
message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
|
|
174
|
-
data: {
|
|
175
|
-
recoveredFromSeq: plan.lastEventSeq,
|
|
176
|
-
resumableTasks: plan.resumableTasks,
|
|
177
|
-
},
|
|
207
|
+
registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
|
|
178
208
|
});
|
|
179
|
-
registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
|
|
180
209
|
}
|
|
181
210
|
|
|
182
211
|
export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">): void {
|
|
183
212
|
const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
184
213
|
if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
214
|
+
// R14-4: re-read inside the lock so the decline decision applies to the
|
|
215
|
+
// on-disk state, not the best-effort snapshot read above. Kept SYNC —
|
|
216
|
+
// withRunLockSync is the sync lock variant.
|
|
217
|
+
withRunLockSync(loaded.manifest, () => {
|
|
218
|
+
const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
|
|
219
|
+
if (!fresh) return;
|
|
220
|
+
// Log the event first — if appendEvent fails, state remains consistent.
|
|
221
|
+
appendEvent(fresh.manifest.eventsPath, {
|
|
222
|
+
type: "crew.run.recovery_declined",
|
|
223
|
+
runId: plan.runId,
|
|
224
|
+
message: "Interrupted run was not resumed.",
|
|
225
|
+
data: { recoveredFromSeq: plan.lastEventSeq },
|
|
226
|
+
});
|
|
227
|
+
updateRunStatus(fresh.manifest, "cancelled", "interrupted-not-resumed");
|
|
191
228
|
});
|
|
192
|
-
updateRunStatus(loaded.manifest, "cancelled", "interrupted-not-resumed");
|
|
193
229
|
}
|
|
194
230
|
|
|
195
231
|
/**
|
|
@@ -224,9 +260,14 @@ export function cancelOrphanedRuns(
|
|
|
224
260
|
// Phase 1: Scan project-level manifests via manifestCache
|
|
225
261
|
for (const manifest of manifestCache.list(50)) {
|
|
226
262
|
if (manifest.status !== "running" && manifest.status !== "blocked") continue;
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
263
|
+
// B1 battery 2026-08-18 (case b): preserve runs intentionally blocked on a
|
|
264
|
+
// HUMAN DECISION — plan approval (pre-v2 semantics) OR a pending ask answer
|
|
265
|
+
// within the waiting TTL (ADR-0 item 9: "not crashes and must not be
|
|
266
|
+
// stale-repaired or cancelled by reconciliation"). Live proof: a resumed
|
|
267
|
+
// run parked on ask (worker alive, waitState set, parked workers do NOT
|
|
268
|
+
// heartbeat) was orphan-cancelled 5m18s into a 10-minute park by a THIRD
|
|
269
|
+
// session's startup scan because the predicate only knew plan approval.
|
|
270
|
+
if (isIntentionalWait(manifest)) {
|
|
230
271
|
skipped.push(manifest.runId);
|
|
231
272
|
continue;
|
|
232
273
|
}
|
|
@@ -242,6 +283,22 @@ export function cancelOrphanedRuns(
|
|
|
242
283
|
continue;
|
|
243
284
|
}
|
|
244
285
|
|
|
286
|
+
// Foreground (in-process) runs have NO async worker PID, so the liveness
|
|
287
|
+
// check above cannot apply. They execute inside THIS pi process — a
|
|
288
|
+
// recently-updated manifest conclusively proves the run is alive; only a
|
|
289
|
+
// run that has gone quiet for the stale threshold can be an orphan (a
|
|
290
|
+
// crashed process stops updating). Without this, a YOUNG foreground run
|
|
291
|
+
// (first worker heartbeat not yet written) was orphan-cancelled by the
|
|
292
|
+
// session that opened its agent view seconds after the run started —
|
|
293
|
+
// killing the very run the user was looking at.
|
|
294
|
+
if (!manifest.async) {
|
|
295
|
+
const updatedMs = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Number.NaN;
|
|
296
|
+
if (Number.isFinite(updatedMs) && now - updatedMs <= staleThresholdMs) {
|
|
297
|
+
skipped.push(manifest.runId);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
245
302
|
// Check for recent heartbeat activity
|
|
246
303
|
const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
247
304
|
if (!loaded) continue;
|
|
@@ -293,6 +350,14 @@ export function cancelOrphanedRuns(
|
|
|
293
350
|
});
|
|
294
351
|
|
|
295
352
|
saveRunTasks(fresh.manifest, repairedTasks);
|
|
353
|
+
// B1 battery 2026-08-18 (case b leak fix): a cancelled run must not keep
|
|
354
|
+
// its waitState pointer — observed live: orphan-cancel left waitState set,
|
|
355
|
+
// shielding the dead run from staleness repair for the full 24h TTL.
|
|
356
|
+
if (fresh.manifest.waitState) {
|
|
357
|
+
const cleared = { ...fresh.manifest, waitState: undefined, updatedAt: new Date(now).toISOString() };
|
|
358
|
+
saveRunManifest(cleared);
|
|
359
|
+
fresh.manifest = cleared;
|
|
360
|
+
}
|
|
296
361
|
for (const task of repairedTasks) {
|
|
297
362
|
try {
|
|
298
363
|
upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
|
|
@@ -655,7 +720,7 @@ export function reconcileAllStaleRuns(
|
|
|
655
720
|
// Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
|
|
656
721
|
// may have flipped to blocked+plan-approval between cache-list and lock
|
|
657
722
|
// acquisition — re-check the freshest manifest under the lock.
|
|
658
|
-
if (
|
|
723
|
+
if (isPlanApprovalPendingEffective(fresh.manifest)) {
|
|
659
724
|
results.push({
|
|
660
725
|
runId,
|
|
661
726
|
verdict: "blocked_awaiting_approval",
|