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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { projectOverrideWarning, sanitizeProjectConfig } from "../config/sanitize-project-config.ts";
|
|
5
|
+
import type { PiTeamsConfig } from "../config/types.ts";
|
|
4
6
|
import { atomicWriteJson } from "../state/atomic-write.ts";
|
|
5
7
|
import { withFileLockSync } from "../state/coordination/locks.ts";
|
|
6
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
@@ -12,6 +14,11 @@ export interface CrewSettings {
|
|
|
12
14
|
graceTurns?: number;
|
|
13
15
|
defaultJoinMode?: JoinMode;
|
|
14
16
|
schedulingEnabled?: boolean;
|
|
17
|
+
/** Wave B2: user-tier-only opt-in for project-tier scheduledJobs. Semantics
|
|
18
|
+
* are enforced at the tiers layer (loadCrewSettingsTiers), NOT here — the
|
|
19
|
+
* flat sanitizeSettings allowlist must merely preserve the flag so the
|
|
20
|
+
* opt-in survives the read of the user-tier global file. */
|
|
21
|
+
allowProjectScheduledJobs?: boolean;
|
|
15
22
|
notifierIntervalMs?: number;
|
|
16
23
|
/** Scheduled jobs loaded from settings — opaque, passed to crewScheduler */
|
|
17
24
|
scheduledJobs?: unknown[];
|
|
@@ -61,6 +68,9 @@ function sanitizeSettings(raw: unknown): CrewSettings {
|
|
|
61
68
|
if (typeof r.schedulingEnabled === "boolean") {
|
|
62
69
|
out.schedulingEnabled = r.schedulingEnabled;
|
|
63
70
|
}
|
|
71
|
+
if (typeof r.allowProjectScheduledJobs === "boolean") {
|
|
72
|
+
out.allowProjectScheduledJobs = r.allowProjectScheduledJobs;
|
|
73
|
+
}
|
|
64
74
|
if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
|
|
65
75
|
out.notifierIntervalMs = r.notifierIntervalMs;
|
|
66
76
|
}
|
|
@@ -89,6 +99,21 @@ function readSettingsFile(filePath: string): CrewSettings {
|
|
|
89
99
|
}
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
/**
|
|
103
|
+
* LEGACY merged view — USER-TIER-ONLY ingestion path (P1 fix follow-up, bug-026 adjacent).
|
|
104
|
+
*
|
|
105
|
+
* Merges the user-tier global file OVER the project file into one flat object.
|
|
106
|
+
* This view is trusted and MUST NOT be applied to live config anymore: the
|
|
107
|
+
* project fragment bypasses `sanitizeProjectConfig`. The current consumer
|
|
108
|
+
* path is `loadCrewSettingsTiers()` + `applyCrewSettingsTiersToConfig()`
|
|
109
|
+
* (project fragment routed through the schema-driven tiering, Wave 2B).
|
|
110
|
+
*
|
|
111
|
+
* If you need to read crew settings for CONFIG APPLICATION, use the tiers
|
|
112
|
+
* API — never this function. Kept exported for back-compat reads/tests;
|
|
113
|
+
* adding a new consumer that writes into loadedConfig re-introduces the
|
|
114
|
+
* fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
|
|
115
|
+
* and the Wave 2B execution log in docs/refactor-plan.md).
|
|
116
|
+
*/
|
|
92
117
|
export function loadCrewSettings(cwd: string = process.cwd()): CrewSettings {
|
|
93
118
|
return {
|
|
94
119
|
...readSettingsFile(globalPath()),
|
|
@@ -135,6 +160,21 @@ export function updateCrewSettings(cwd: string, mutator: (settings: CrewSettings
|
|
|
135
160
|
});
|
|
136
161
|
}
|
|
137
162
|
|
|
163
|
+
/**
|
|
164
|
+
* LEGACY direct-apply — USER-TIER-ONLY (P1 fix follow-up).
|
|
165
|
+
*
|
|
166
|
+
* Writes settings straight into a config object with NO sanitize tiering.
|
|
167
|
+
* Applying a merged (user+project) `CrewSettings` through this function is
|
|
168
|
+
* exactly the P1 bypass fixed in Wave 2B: project-tier values would land in
|
|
169
|
+
* guard fields (`limits.maxConcurrentWorkers`, `runtime.maxTurns`, ...) with
|
|
170
|
+
* no conditional drops. The tiered replacement is
|
|
171
|
+
* `applyCrewSettingsTiersToConfig()` which routes the project fragment
|
|
172
|
+
* through `sanitizeProjectConfig` + tighten-only comparison.
|
|
173
|
+
*
|
|
174
|
+
* Do NOT add new call sites that pass a merged/project-influenced settings
|
|
175
|
+
* object. Acceptable use: tests, and user-tier-only tooling that has already
|
|
176
|
+
* separated tiers.
|
|
177
|
+
*/
|
|
138
178
|
export function applyCrewSettingsToConfig(
|
|
139
179
|
config: {
|
|
140
180
|
limits?: { maxConcurrentWorkers?: number };
|
|
@@ -153,3 +193,182 @@ export function applyCrewSettingsToConfig(
|
|
|
153
193
|
if (settings.defaultJoinMode != null && config.runtime) config.runtime.groupJoin = settings.defaultJoinMode;
|
|
154
194
|
if (settings.notifierIntervalMs != null) config.notifierIntervalMs = settings.notifierIntervalMs;
|
|
155
195
|
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Wave 2B (ITEM 1, P1 security): tiered loading + guarded apply.
|
|
199
|
+
//
|
|
200
|
+
// `<cwd>/.pi/crew-settings.json` is PROJECT-tier state — a cloned repository
|
|
201
|
+
// can ship it. `loadCrewSettings()` historically merged it OVER the user-tier
|
|
202
|
+
// `~/.pi/crew-settings.json` with only the type/range allowlist
|
|
203
|
+
// (`sanitizeSettings`), and `applyCrewSettingsToConfig` then wrote the merged
|
|
204
|
+
// result into guard fields (limits.maxConcurrentWorkers,
|
|
205
|
+
// runtime.maxTurns/graceTurns) AFTER `loadConfig()` had already sanitized the
|
|
206
|
+
// regular project config files — a full bypass of `sanitizeProjectConfig`.
|
|
207
|
+
// The functions below close that hole.
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/** Tiered view of crew settings (see `loadCrewSettingsTiers`). */
|
|
211
|
+
export interface CrewSettingsTiers {
|
|
212
|
+
/** User-tier `~/.pi/crew-settings.json` — fully trusted. */
|
|
213
|
+
user: CrewSettings;
|
|
214
|
+
/** Project-tier `<cwd>/.pi/crew-settings.json` — UNTRUSTED input. */
|
|
215
|
+
project: CrewSettings;
|
|
216
|
+
/** Historical merge view (project wins over user). Kept for the write-path
|
|
217
|
+
* round-trip (`updateCrewSettings`). NOT the registration source for
|
|
218
|
+
* scheduledJobs anymore — use `effectiveScheduledJobs` (Wave B2 gate). */
|
|
219
|
+
merged: CrewSettings;
|
|
220
|
+
/** Wave B2 GATE — what consumers register: user-tier scheduledJobs plus
|
|
221
|
+
* project-tier scheduledJobs ONLY when the user tier explicitly opted in
|
|
222
|
+
* (user.schedulingEnabled === true && user.allowProjectScheduledJobs ===
|
|
223
|
+
* true). User-first concat order. */
|
|
224
|
+
effectiveScheduledJobs: unknown[];
|
|
225
|
+
/** Absolute path of the project-tier file (used in warnings). */
|
|
226
|
+
projectPath: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Wave B2 gate predicate: the user tier opts in to project-tier scheduledJobs
|
|
230
|
+
* only when BOTH flags are explicitly `true` in the user-tier global file.
|
|
231
|
+
* Any other combination (flags absent, either false) opts OUT. */
|
|
232
|
+
function projectScheduledJobsOptIn(user: CrewSettings): boolean {
|
|
233
|
+
return user.schedulingEnabled === true && user.allowProjectScheduledJobs === true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Load crew settings keeping the trust tiers distinguishable. The optional
|
|
238
|
+
* `globalFile` parameter defaults to `~/.pi/crew-settings.json` and exists so
|
|
239
|
+
* callers/tests can pin a hermetic user-tier file.
|
|
240
|
+
*/
|
|
241
|
+
export function loadCrewSettingsTiers(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettingsTiers {
|
|
242
|
+
const user = readSettingsFile(globalFile);
|
|
243
|
+
const projectFilePath = projectPath(cwd);
|
|
244
|
+
const project = readSettingsFile(projectFilePath);
|
|
245
|
+
const effectiveScheduledJobs: unknown[] = [
|
|
246
|
+
...(user.scheduledJobs ?? []),
|
|
247
|
+
...(projectScheduledJobsOptIn(user) ? (project.scheduledJobs ?? []) : []),
|
|
248
|
+
];
|
|
249
|
+
return { user, project, merged: { ...user, ...project }, effectiveScheduledJobs, projectPath: projectFilePath };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Apply crew settings to a config with correct trust tiering. Returns the
|
|
254
|
+
* list of drop warnings (standard project-override format).
|
|
255
|
+
*
|
|
256
|
+
* Pipeline for the PROJECT tier (the user tier is fully trusted and applied
|
|
257
|
+
* exactly like the legacy merged semantics):
|
|
258
|
+
* 1. CHOKE POINT — the fragment is mapped into `PiTeamsConfig` shape and run
|
|
259
|
+
* through `sanitizeProjectConfig`, the same schema-driven sanitize every
|
|
260
|
+
* other project-scoped config ingestion passes through (config.ts applies
|
|
261
|
+
* it to pi-teams.json and .pi/pi-crew.json). Today none of the mapped
|
|
262
|
+
* fields are sensitive-marked, so this is behavior-preserving — but any
|
|
263
|
+
* future `sensitive: true` mark or CONDITIONAL_PROJECT_DROPS entry on
|
|
264
|
+
* these paths automatically applies to crew-settings too.
|
|
265
|
+
* 2. TIGHTEN-ONLY TIERING — `limits.maxConcurrentWorkers`,
|
|
266
|
+
* `runtime.maxTurns` and `runtime.graceTurns` are resource guards: a
|
|
267
|
+
* project-tier value that RAISES the effective value (baseline = config
|
|
268
|
+
* after the trusted user tier was applied) is dropped with the standard
|
|
269
|
+
* warning; lowering/equal survives. A value with NO baseline is also
|
|
270
|
+
* dropped: introducing a limit the user never set cannot be verified as
|
|
271
|
+
* tightening, and the only legitimate writer (updateCrewSettings
|
|
272
|
+
* spreading merged settings into the project file) always mirrors a
|
|
273
|
+
* user-tier value, so the equal case survives. This comparison lives HERE
|
|
274
|
+
* and not in CONDITIONAL_PROJECT_DROPS: that table is a fixed value
|
|
275
|
+
* predicate with no access to the effective-config baseline (Wave 2B
|
|
276
|
+
* decision — predicate signature intentionally unchanged).
|
|
277
|
+
* 3. Survivors are applied over the user tier — safe by construction since
|
|
278
|
+
* survivors can only be equal-or-lower guard values.
|
|
279
|
+
*
|
|
280
|
+
* BOUNDARY (Wave B2 — was ITEM 1.4): scheduling is a USER-TIER decision.
|
|
281
|
+
* `schedulingEnabled` is never honored from the project tier — a project file
|
|
282
|
+
* can neither enable nor disable scheduling (no consumer reads it in src/
|
|
283
|
+
* today; if one is added it MUST read only the user tier). Project-tier
|
|
284
|
+
* `scheduledJobs` are DROPPED (warning emitted below + left out of the gated
|
|
285
|
+
* `effectiveScheduledJobs` view that the scheduler registration consumer
|
|
286
|
+
* loops) unless the user-tier global file explicitly opts in with BOTH
|
|
287
|
+
* `schedulingEnabled: true` AND `allowProjectScheduledJobs: true`. The
|
|
288
|
+
* tighten-only philosophy is intentionally NOT applied here: jobs are not
|
|
289
|
+
* numeric bounds — allowing them is an availability decision the human user
|
|
290
|
+
* makes in their global file. UX consequence (accepted): `crew schedule
|
|
291
|
+
* add/update/remove` persists jobs into the PROJECT file (handle-schedule.ts),
|
|
292
|
+
* so crew-schedule users must set both flags in ~/.pi/crew-settings.json or
|
|
293
|
+
* their own jobs stop registering on session_start.
|
|
294
|
+
*
|
|
295
|
+
* `notifierIntervalMs` (UI poll cadence) and `runtime.groupJoin` (join
|
|
296
|
+
* semantics) are not guard fields and ride the choke point only.
|
|
297
|
+
*/
|
|
298
|
+
export function applyCrewSettingsTiersToConfig(config: PiTeamsConfig, tiers: CrewSettingsTiers): string[] {
|
|
299
|
+
const warnings: string[] = [];
|
|
300
|
+
// User tier: fully trusted, identical to the legacy merged semantics.
|
|
301
|
+
applyCrewSettingsToConfig(config, tiers.user);
|
|
302
|
+
|
|
303
|
+
const project = tiers.project;
|
|
304
|
+
// Wave B2 scheduling gate — MUST run BEFORE the empty-fragment early return
|
|
305
|
+
// below: a project file containing ONLY scheduling fields would otherwise
|
|
306
|
+
// skip the warnings entirely. Both warnings are warning-only: neither field
|
|
307
|
+
// maps into PiTeamsConfig; registration consumers read the gated
|
|
308
|
+
// `effectiveScheduledJobs` view instead.
|
|
309
|
+
if (project.schedulingEnabled !== undefined) {
|
|
310
|
+
// Project can neither enable nor disable scheduling — always dropped.
|
|
311
|
+
warnings.push(projectOverrideWarning(tiers.projectPath, "schedulingEnabled"));
|
|
312
|
+
}
|
|
313
|
+
if (!projectScheduledJobsOptIn(tiers.user) && Array.isArray(project.scheduledJobs) && project.scheduledJobs.length > 0) {
|
|
314
|
+
warnings.push(projectOverrideWarning(tiers.projectPath, "scheduledJobs"));
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
project.maxConcurrent == null &&
|
|
318
|
+
project.defaultMaxTurns == null &&
|
|
319
|
+
project.graceTurns == null &&
|
|
320
|
+
project.defaultJoinMode == null &&
|
|
321
|
+
project.notifierIntervalMs == null
|
|
322
|
+
) {
|
|
323
|
+
return warnings;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const fragment: PiTeamsConfig = {};
|
|
327
|
+
if (project.maxConcurrent != null) fragment.limits = { maxConcurrentWorkers: project.maxConcurrent };
|
|
328
|
+
if (project.defaultMaxTurns != null || project.graceTurns != null || project.defaultJoinMode != null) {
|
|
329
|
+
const runtime: NonNullable<PiTeamsConfig["runtime"]> = {};
|
|
330
|
+
if (project.defaultMaxTurns != null) runtime.maxTurns = project.defaultMaxTurns;
|
|
331
|
+
if (project.graceTurns != null) runtime.graceTurns = project.graceTurns;
|
|
332
|
+
// Legacy quirk (kept for behavior parity): crew-settings JoinMode allows
|
|
333
|
+
// "async", which is not part of runtime.groupJoin's union — the old
|
|
334
|
+
// structural param type let this through implicitly.
|
|
335
|
+
if (project.defaultJoinMode != null)
|
|
336
|
+
runtime.groupJoin = project.defaultJoinMode as NonNullable<PiTeamsConfig["runtime"]>["groupJoin"];
|
|
337
|
+
fragment.runtime = runtime;
|
|
338
|
+
}
|
|
339
|
+
if (project.notifierIntervalMs != null) fragment.notifierIntervalMs = project.notifierIntervalMs;
|
|
340
|
+
|
|
341
|
+
const sanitized = sanitizeProjectConfig(tiers.projectPath, config, fragment);
|
|
342
|
+
warnings.push(...sanitized.warnings);
|
|
343
|
+
|
|
344
|
+
const guards: Array<{ dotted: string; value: number | undefined; baseline: number | undefined }> = [
|
|
345
|
+
{
|
|
346
|
+
dotted: "limits.maxConcurrentWorkers",
|
|
347
|
+
value: sanitized.config.limits?.maxConcurrentWorkers,
|
|
348
|
+
baseline: config.limits?.maxConcurrentWorkers,
|
|
349
|
+
},
|
|
350
|
+
{ dotted: "runtime.maxTurns", value: sanitized.config.runtime?.maxTurns, baseline: config.runtime?.maxTurns },
|
|
351
|
+
{ dotted: "runtime.graceTurns", value: sanitized.config.runtime?.graceTurns, baseline: config.runtime?.graceTurns },
|
|
352
|
+
];
|
|
353
|
+
const dropped = new Set<string>();
|
|
354
|
+
for (const guard of guards) {
|
|
355
|
+
if (guard.value === undefined) continue;
|
|
356
|
+
if (guard.baseline === undefined || guard.value > guard.baseline) {
|
|
357
|
+
dropped.add(guard.dotted);
|
|
358
|
+
warnings.push(projectOverrideWarning(tiers.projectPath, guard.dotted));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!dropped.has("limits.maxConcurrentWorkers") && sanitized.config.limits?.maxConcurrentWorkers != null && config.limits) {
|
|
363
|
+
config.limits.maxConcurrentWorkers = sanitized.config.limits.maxConcurrentWorkers;
|
|
364
|
+
}
|
|
365
|
+
if (sanitized.config.runtime != null && config.runtime) {
|
|
366
|
+
if (!dropped.has("runtime.maxTurns") && sanitized.config.runtime.maxTurns != null)
|
|
367
|
+
config.runtime.maxTurns = sanitized.config.runtime.maxTurns;
|
|
368
|
+
if (!dropped.has("runtime.graceTurns") && sanitized.config.runtime.graceTurns != null)
|
|
369
|
+
config.runtime.graceTurns = sanitized.config.runtime.graceTurns;
|
|
370
|
+
if (sanitized.config.runtime.groupJoin != null) config.runtime.groupJoin = sanitized.config.runtime.groupJoin;
|
|
371
|
+
}
|
|
372
|
+
if (sanitized.config.notifierIntervalMs != null) config.notifierIntervalMs = sanitized.config.notifierIntervalMs;
|
|
373
|
+
return warnings;
|
|
374
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn policy for delegate-driven grandchild admission (ADR-5, WP-5 step 3).
|
|
3
|
+
*
|
|
4
|
+
* Pure evaluation — no I/O, no state mutation. The root-side delegate handler
|
|
5
|
+
* (broker `delegate` method, WP-5 step 5) gathers the inputs and calls
|
|
6
|
+
* `evaluateDelegateAdmission`; on denial it returns the fail-fast message to
|
|
7
|
+
* the worker and logs `delegate.rejected` to events.jsonl (never silent).
|
|
8
|
+
*
|
|
9
|
+
* Gate dimensions (each with a distinct machine-readable reason + fail-fast
|
|
10
|
+
* message; evaluated in this order — cheapest/most-global first):
|
|
11
|
+
* 1. nesting-disabled — `nesting.enabled` is false (DEFAULT; fail-closed)
|
|
12
|
+
* 2. role-denied — the PARENT task's role is not executor-class
|
|
13
|
+
* (read-only roles cannot raise spawn privileges),
|
|
14
|
+
* or the requested grandchild role is not one of
|
|
15
|
+
* the delegate-tool's allowed roles
|
|
16
|
+
* 3. trust-denied — untrusted escalation context (manual-only trust
|
|
17
|
+
* gate; the handler resolves trust, policy enforces)
|
|
18
|
+
* 4. depth-exceeded — child depth (parent.depth + 1) exceeds the
|
|
19
|
+
* resolved maxDepth (default 2). Depth comes from
|
|
20
|
+
* the parent task RECORD, never worker env
|
|
21
|
+
* (design §7 rev-2 P0-2)
|
|
22
|
+
* 5. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
|
|
23
|
+
* fail-fast, never queue
|
|
24
|
+
* 6. budget-insufficient— requested budgetTokens exceed the parent task's
|
|
25
|
+
* remaining allocation (tokensGranted - tokensSpent)
|
|
26
|
+
* 7. model-invalid — requested model is not in the resolved model
|
|
27
|
+
* catalog (the unvalidated provider/model
|
|
28
|
+
* pass-through — model-fallback.ts:282, the
|
|
29
|
+
* 429-cascade root — must not be reachable here)
|
|
30
|
+
* 8. timeout-invalid — timeoutSec outside 1..MAX (default 900)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { modelRefToString } from "./model/model-fallback.ts";
|
|
34
|
+
import type { NestedSlotSnapshot } from "./scheduling/nested-slots.ts";
|
|
35
|
+
|
|
36
|
+
/** Delegate tool's allowed grandchild roles (design §7 surface). */
|
|
37
|
+
export const DELEGATE_ALLOWED_ROLES = ["explorer", "analyst", "executor"] as const;
|
|
38
|
+
export type DelegateRole = (typeof DELEGATE_ALLOWED_ROLES)[number];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Roles permitted to CALL delegate (executor-class). Read-only/review roles
|
|
42
|
+
* (explorer, analyst, planner, critic, reviewer, verifier, writer,
|
|
43
|
+
* security-reviewer) are denied — delegation is a spawn privilege.
|
|
44
|
+
*/
|
|
45
|
+
export const EXECUTOR_CLASS_ROLES = ["executor", "test-engineer"] as const;
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
|
|
48
|
+
export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
|
|
49
|
+
|
|
50
|
+
export type SpawnPolicyDenyReason =
|
|
51
|
+
| "nesting-disabled"
|
|
52
|
+
| "role-denied"
|
|
53
|
+
| "trust-denied"
|
|
54
|
+
| "depth-exceeded"
|
|
55
|
+
| "slots-exhausted"
|
|
56
|
+
| "budget-insufficient"
|
|
57
|
+
| "model-invalid"
|
|
58
|
+
| "timeout-invalid"
|
|
59
|
+
| "workspace-conflict";
|
|
60
|
+
|
|
61
|
+
export interface DelegateAdmissionInput {
|
|
62
|
+
/** Resolved `nesting.enabled` config (DEFAULT false — fail-closed, ADR-5 §10). */
|
|
63
|
+
nestingEnabled: boolean;
|
|
64
|
+
/** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
|
|
65
|
+
maxDepth: number;
|
|
66
|
+
/** Parent task record fields the policy is allowed to trust. */
|
|
67
|
+
parentTask: {
|
|
68
|
+
taskId: string;
|
|
69
|
+
role: string;
|
|
70
|
+
/** Absent on pre-v2 records — treated as depth 1 (a worker). */
|
|
71
|
+
depth?: number;
|
|
72
|
+
allocation?: { tokensGranted?: number; tokensSpent?: number };
|
|
73
|
+
};
|
|
74
|
+
/** Untrusted escalation context — the delegate handler resolves trust. */
|
|
75
|
+
untrusted?: boolean;
|
|
76
|
+
/** Nested-slot budget snapshot (used/max). */
|
|
77
|
+
slots: NestedSlotSnapshot;
|
|
78
|
+
/** Requested grandchild parameters (all optional in the tool surface). */
|
|
79
|
+
requested?: {
|
|
80
|
+
role?: string;
|
|
81
|
+
model?: string;
|
|
82
|
+
budgetTokens?: number;
|
|
83
|
+
timeoutSec?: number;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Resolved model catalog as canonical `provider/id` strings. When omitted
|
|
87
|
+
* (catalog unavailable at the call-site) model validation is SKIPPED —
|
|
88
|
+
* flag this in review; the broker handler must always supply it.
|
|
89
|
+
*/
|
|
90
|
+
modelCatalog?: readonly string[];
|
|
91
|
+
/** ADR-5 §9 workspace interaction: when the requested grandchild role is
|
|
92
|
+
* write-capable (executor-class), an overlapping in-flight executor can
|
|
93
|
+
* only be tolerated when serialization is established. `serializeEnabled`
|
|
94
|
+
* mirrors config `limits.serializeOnPathOverlap`; `overlappingInFlightExecutors`
|
|
95
|
+
* counts OTHER running executor-class tasks sharing the parent task's cwd. */
|
|
96
|
+
workspace?: {
|
|
97
|
+
serializeEnabled?: boolean;
|
|
98
|
+
overlappingInFlightExecutors?: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SpawnPolicyDecision {
|
|
103
|
+
allowed: boolean;
|
|
104
|
+
/** Machine-readable deny reason (undefined when allowed). */
|
|
105
|
+
reason?: SpawnPolicyDenyReason;
|
|
106
|
+
/** Fail-fast human message (undefined when allowed). */
|
|
107
|
+
message?: string;
|
|
108
|
+
/** The grandchild's depth (parent depth + 1) on success. */
|
|
109
|
+
childDepth?: number;
|
|
110
|
+
/** Effective timeout in seconds (requested or default 900) on success. */
|
|
111
|
+
timeoutSec?: number;
|
|
112
|
+
/** Effective model (normalized) on success. */
|
|
113
|
+
model?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isExecutorClassRole(role: string): boolean {
|
|
117
|
+
return (EXECUTOR_CLASS_ROLES as readonly string[]).includes(role);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecision {
|
|
121
|
+
return { allowed: false, reason, message };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
|
|
125
|
+
const { nestingEnabled, maxDepth, parentTask, requested } = input;
|
|
126
|
+
const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
|
|
127
|
+
const childDepth = parentDepth + 1;
|
|
128
|
+
|
|
129
|
+
// 1. Flag gate — fail-closed default (ADR-5 §10).
|
|
130
|
+
if (!nestingEnabled) {
|
|
131
|
+
return deny(
|
|
132
|
+
"nesting-disabled",
|
|
133
|
+
"delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. Role gate — parent must be executor-class; requested role must be in
|
|
138
|
+
// the tool's allowed set.
|
|
139
|
+
if (!isExecutorClassRole(parentTask.role)) {
|
|
140
|
+
return deny(
|
|
141
|
+
"role-denied",
|
|
142
|
+
`delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const requestedRole = requested?.role;
|
|
146
|
+
if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
|
|
147
|
+
return deny(
|
|
148
|
+
"role-denied",
|
|
149
|
+
`delegate rejected: requested grandchild role '${requestedRole}' is not one of ${DELEGATE_ALLOWED_ROLES.join("/")}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Trust gate — untrusted escalation context never spawns.
|
|
154
|
+
if (input.untrusted) {
|
|
155
|
+
return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. Depth gate — computed from the parent RECORD (never worker env).
|
|
159
|
+
if (childDepth > maxDepth) {
|
|
160
|
+
return deny(
|
|
161
|
+
"depth-exceeded",
|
|
162
|
+
`delegate rejected: spawn depth ${childDepth} exceeds maxDepth ${maxDepth} (parent task '${parentTask.taskId}' is at depth ${parentDepth})`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 5. Nested-slot budget — fail-fast, never queue.
|
|
167
|
+
if (input.slots.used >= input.slots.max) {
|
|
168
|
+
return deny(
|
|
169
|
+
"slots-exhausted",
|
|
170
|
+
`delegate rejected: nested spawn budget exhausted; ${input.slots.used}/${input.slots.max} in flight`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 6. Parent allocation sufficiency — reserve up-front or reject.
|
|
175
|
+
if (requested?.budgetTokens !== undefined) {
|
|
176
|
+
const granted = parentTask.allocation?.tokensGranted ?? 0;
|
|
177
|
+
const spent = parentTask.allocation?.tokensSpent ?? 0;
|
|
178
|
+
const remaining = granted - spent;
|
|
179
|
+
if (requested.budgetTokens > remaining) {
|
|
180
|
+
return deny(
|
|
181
|
+
"budget-insufficient",
|
|
182
|
+
`delegate rejected: requested budget ${requested.budgetTokens} tokens exceeds parent task '${parentTask.taskId}' remaining allocation ${remaining} (${granted} granted - ${spent} spent)`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 7. Model validation against the resolved catalog.
|
|
188
|
+
const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
|
|
189
|
+
if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
|
|
190
|
+
if (!input.modelCatalog.includes(normalizedModel)) {
|
|
191
|
+
return deny("model-invalid", `delegate rejected: model '${requested?.model}' is not in the resolved model catalog`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 8. Timeout validation (mandatory default 900).
|
|
196
|
+
const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
|
|
197
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
|
|
198
|
+
return deny(
|
|
199
|
+
"timeout-invalid",
|
|
200
|
+
`delegate rejected: timeoutSec ${String(requested?.timeoutSec)} is outside 1..${MAX_DELEGATE_TIMEOUT_SEC} (default ${DEFAULT_DELEGATE_TIMEOUT_SEC})`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 9. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
|
|
205
|
+
// the parent's cwd with ANOTHER in-flight executor is only admitted when
|
|
206
|
+
// serialization is established (limits.serializeOnPathOverlap) — otherwise
|
|
207
|
+
// reject; read-only grandchild roles (explorer/analyst) never conflict.
|
|
208
|
+
const requestedIsWriteCapable = requestedRole !== undefined && (EXECUTOR_CLASS_ROLES as readonly string[]).includes(requestedRole);
|
|
209
|
+
if (requestedIsWriteCapable && !input.workspace?.serializeEnabled && (input.workspace?.overlappingInFlightExecutors ?? 0) > 0) {
|
|
210
|
+
return deny(
|
|
211
|
+
"workspace-conflict",
|
|
212
|
+
`delegate rejected: parent task '${parentTask.taskId}' cwd overlaps ${input.workspace?.overlappingInFlightExecutors} in-flight executor(s) and limits.serializeOnPathOverlap is off — use a read-only grandchild role (explorer/analyst) or enable serialization`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { allowed: true, childDepth, timeoutSec, ...(normalizedModel !== undefined ? { model: normalizedModel } : {}) };
|
|
217
|
+
}
|
|
@@ -3,8 +3,10 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { errors } from "../errors.ts";
|
|
5
5
|
import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
|
|
6
|
+
import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
|
|
6
7
|
import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
|
|
7
8
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
9
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
10
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
9
11
|
import { checkProcessLiveness } from "./process-status.ts";
|
|
10
12
|
|
|
@@ -23,7 +25,7 @@ const ORPHAN_TEMP_SCAN_BATCH_SIZE = 50;
|
|
|
23
25
|
export interface ReconcileResult {
|
|
24
26
|
runId: string;
|
|
25
27
|
/** What was found and what action was taken */
|
|
26
|
-
verdict: "healthy" | "blocked_awaiting_approval" | "result_exists" | "pid_dead" | "pid_alive_stale" | "no_status";
|
|
28
|
+
verdict: "healthy" | "blocked_awaiting_approval" | "waiting_answer" | "result_exists" | "pid_dead" | "pid_alive_stale" | "no_status";
|
|
27
29
|
/** Whether repair was applied */
|
|
28
30
|
repaired: boolean;
|
|
29
31
|
/** Human-readable detail */
|
|
@@ -45,6 +47,50 @@ export function isPlanApprovalPending(manifest: TeamRunManifest): boolean {
|
|
|
45
47
|
return manifest.status === "blocked" && manifest.planApproval?.required === true && manifest.planApproval.status === "pending";
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** T2/R4 (ADR-4 §2 reader migration): plan-record-first variant — the current
|
|
51
|
+
* PlanRecord's approval (when present) is authoritative; pre-v2 runs fall
|
|
52
|
+
* back to the manifest gate above. The `blocked + pending → protected`
|
|
53
|
+
* invariant is preserved on BOTH paths. */
|
|
54
|
+
export function isPlanApprovalPendingEffective(manifest: TeamRunManifest): boolean {
|
|
55
|
+
if (isPlanApprovalPending(manifest)) return true;
|
|
56
|
+
if (manifest.status !== "blocked") return false;
|
|
57
|
+
return getCurrentPlanRecord(manifest)?.approval?.status === "pending";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** WP-2/R2 (ADR-0 item 9, docs/decisions/2026-08-17-waiting-producer-ask.md):
|
|
61
|
+
* waiting TTL — a manifest.waitState.askedAt younger than this marks an
|
|
62
|
+
* intentional ask-wait; older marks a leaked park marker (worker died without
|
|
63
|
+
* resolving the ask) and the run loses reconciler protection. Leak guard. */
|
|
64
|
+
const WAITING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Is this run parked in the ask tool awaiting a human answer (WP-2/R2)?
|
|
68
|
+
*
|
|
69
|
+
* True when waitState is set and its askedAt is within waitingTtl. The parked
|
|
70
|
+
* worker is blocked polling the mailbox for a response — not crashed — and it
|
|
71
|
+
* typically stops heartbeating while parked, so the run must be protected at
|
|
72
|
+
* run level (before the no-PID/heartbeat staleness phases) exactly like
|
|
73
|
+
* plan-approval runs. askedAt exactly at the TTL boundary still counts as
|
|
74
|
+
* pending (<=, fail-safe toward preservation); a future askedAt (clock skew)
|
|
75
|
+
* also passes. TTL-EXPIRED waitState returns false — the marker leaked and the
|
|
76
|
+
* normal staleness repair paths apply (leak guard).
|
|
77
|
+
*/
|
|
78
|
+
export function isWaitAnswerPending(manifest: TeamRunManifest, now = Date.now()): boolean {
|
|
79
|
+
const askedAtMs = manifest.waitState?.askedAt ? new Date(manifest.waitState.askedAt).getTime() : Number.NaN;
|
|
80
|
+
return Number.isFinite(askedAtMs) && now - askedAtMs <= WAITING_TTL_MS;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Generalized intentional-wait predicate (ADR-0 item 9, extended by ADR-4 §2):
|
|
85
|
+
* the run is blocked on a human decision — plan approval (record-first with
|
|
86
|
+
* manifest fallback — pre-v2 semantics preserved on the fallback path) or a
|
|
87
|
+
* pending ask answer within waitingTtl. Such runs are not crashes and must not
|
|
88
|
+
* be stale-repaired or cancelled by reconciliation.
|
|
89
|
+
*/
|
|
90
|
+
export function isIntentionalWait(manifest: TeamRunManifest, now = Date.now()): boolean {
|
|
91
|
+
return isPlanApprovalPendingEffective(manifest) || isWaitAnswerPending(manifest, now);
|
|
92
|
+
}
|
|
93
|
+
|
|
48
94
|
const STALE_ALIVE_PID_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
49
95
|
const ACTIVE_EVIDENCE_TTL_MS = 5 * 60 * 1000;
|
|
50
96
|
/** For no-PID runs, repair when ALL running tasks have heartbeat stale beyond this threshold. */
|
|
@@ -315,7 +361,9 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
|
|
|
315
361
|
// Preserve runs intentionally blocked on human plan approval. These are not
|
|
316
362
|
// crashes even if the owning PID is gone — they are waiting for a decision.
|
|
317
363
|
// Must short-circuit before Phase 1 (result check) and Phase 2 (PID liveness).
|
|
318
|
-
|
|
364
|
+
// ADR-4 §2: record-first predicate — a PlanRecord approval is authoritative,
|
|
365
|
+
// the manifest field remains the pre-v2 fallback (never dropped).
|
|
366
|
+
if (isPlanApprovalPendingEffective(manifest)) {
|
|
319
367
|
return {
|
|
320
368
|
runId,
|
|
321
369
|
verdict: "blocked_awaiting_approval",
|
|
@@ -324,6 +372,22 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
|
|
|
324
372
|
};
|
|
325
373
|
}
|
|
326
374
|
|
|
375
|
+
// WP-2/R2 (ADR-0 item 9): a run parked in the ask tool with a fresh
|
|
376
|
+
// waitState.askedAt is intentionally waiting on a human answer — not stale.
|
|
377
|
+
// Short-circuits before Phase 1/2 like plan-approval above: a parked worker
|
|
378
|
+
// does not heartbeat, so the no-PID/heartbeat staleness phases would
|
|
379
|
+
// otherwise cancel it long before its (server-clamped <=1h) ask deadline.
|
|
380
|
+
// TTL-expired waitState (>24h) falls through — the park marker leaked
|
|
381
|
+
// (worker died without resolving) and normal staleness repair applies.
|
|
382
|
+
if (isWaitAnswerPending(manifest, now)) {
|
|
383
|
+
return {
|
|
384
|
+
runId,
|
|
385
|
+
verdict: "waiting_answer",
|
|
386
|
+
repaired: false,
|
|
387
|
+
detail: "Ask answer pending (manifest.waitState.askedAt within waiting TTL); run is intentionally waiting and must not be stale-repaired",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
327
391
|
// Phase 1: Check if results already exist
|
|
328
392
|
const phase1 = checkResultFile(manifest, tasks);
|
|
329
393
|
if (phase1.found) {
|
|
@@ -538,7 +602,14 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
538
602
|
}
|
|
539
603
|
}
|
|
540
604
|
// If still running after reconciliation attempt, mark for dir-preserving
|
|
541
|
-
if (
|
|
605
|
+
if (
|
|
606
|
+
result.verdict === "healthy" ||
|
|
607
|
+
// WP-2/R2: a run intentionally waiting on an ask answer keeps the
|
|
608
|
+
// temp workspace alive (its stateRoot + mailbox must survive for
|
|
609
|
+
// the parked worker to receive the answer).
|
|
610
|
+
result.verdict === "waiting_answer" ||
|
|
611
|
+
(result.verdict === "no_status" && !result.repaired)
|
|
612
|
+
) {
|
|
542
613
|
hasRunning = true;
|
|
543
614
|
}
|
|
544
615
|
} catch (err) {
|
|
@@ -546,14 +617,19 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
546
617
|
// Note: manifestPath here refers to the variable defined in the
|
|
547
618
|
// for loop at line 442 (outer scope of this catch block).
|
|
548
619
|
const scanManifestPath = manifestPath;
|
|
549
|
-
|
|
620
|
+
logInternalError(
|
|
621
|
+
"stale-reconciler",
|
|
622
|
+
new Error(`Skipping manifest due to parse error: ${scanManifestPath}: ${err}`),
|
|
623
|
+
undefined,
|
|
624
|
+
"warn",
|
|
625
|
+
);
|
|
550
626
|
}
|
|
551
627
|
}
|
|
552
628
|
} catch (err) {
|
|
553
629
|
// Cannot determine running state — treat as if running to prevent
|
|
554
630
|
// premature cleanup of a potentially active workspace.
|
|
555
631
|
hasRunning = true;
|
|
556
|
-
|
|
632
|
+
logInternalError("stale-reconciler", new Error(`Skipping unreadable runs dir: ${stateRunsDir}: ${err}`), undefined, "warn");
|
|
557
633
|
}
|
|
558
634
|
|
|
559
635
|
// Post-loop: check if this workspace dir can be cleaned up.
|
|
@@ -617,7 +693,12 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
617
693
|
}
|
|
618
694
|
}
|
|
619
695
|
} catch (err) {
|
|
620
|
-
|
|
696
|
+
logInternalError(
|
|
697
|
+
"stale-reconciler",
|
|
698
|
+
new Error(`Skipping unreadable runs dir: ${stateRunsDir}: ${err}`),
|
|
699
|
+
undefined,
|
|
700
|
+
"warn",
|
|
701
|
+
);
|
|
621
702
|
}
|
|
622
703
|
}
|
|
623
704
|
}
|