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
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* Parses a plan document and outputs agent chain commands.
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
|
+
import { currentCrewRole, permissionForRole } from "../../runtime/role-permission.ts";
|
|
7
8
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
9
|
+
import { withRunLock } from "../../state/coordination/locks.ts";
|
|
10
|
+
import { appendPlanRevision } from "../../state/stores/plan-store.ts";
|
|
11
|
+
import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
|
|
8
12
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
9
13
|
import {
|
|
10
14
|
buildAgentChain,
|
|
@@ -12,7 +16,9 @@ import {
|
|
|
12
16
|
type OrchestratedStep,
|
|
13
17
|
parsePlanDocument,
|
|
14
18
|
parsePlanDocumentSimple,
|
|
19
|
+
stepsToPlanRecord,
|
|
15
20
|
} from "../plan-orchestrate.ts";
|
|
21
|
+
import { locateRunCwd } from "../team-tool.ts";
|
|
16
22
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
17
23
|
import { result, type TeamContext } from "./context.ts";
|
|
18
24
|
|
|
@@ -24,7 +30,7 @@ import { result, type TeamContext } from "./context.ts";
|
|
|
24
30
|
*
|
|
25
31
|
* Usage: `team action='orchestrate' planPath='/path/to/plan.md'`
|
|
26
32
|
*/
|
|
27
|
-
export function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
33
|
+
export async function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
28
34
|
const planPath = params.planPath as string | undefined;
|
|
29
35
|
|
|
30
36
|
if (!planPath) {
|
|
@@ -67,9 +73,67 @@ export function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext)
|
|
|
67
73
|
const overview = formatPlanOverview(resolvedPath);
|
|
68
74
|
const commands = buildAgentChain(steps);
|
|
69
75
|
|
|
76
|
+
// T2/R4 (ADR-4 §6 producer 1): persist a PlanRecord when the caller binds
|
|
77
|
+
// an explicit run. Opt-in: orchestrate emits one `team action='run'` per
|
|
78
|
+
// step (each creating its OWN run), so silently attaching the record to
|
|
79
|
+
// "some" run would misattribute linkage. Without runId the action stays
|
|
80
|
+
// read-only and says so.
|
|
81
|
+
let planNote = "PlanRecord not persisted (no runId given — pass runId=<runId> to attach a versioned record to that run).";
|
|
82
|
+
let persisted: { id: string; version: number; items: number } | undefined;
|
|
83
|
+
const runId = typeof params.runId === "string" && params.runId.trim() ? params.runId.trim() : undefined;
|
|
84
|
+
if (runId) {
|
|
85
|
+
// Security (T2 review S1/S2): the persist branch is a run-mutating write.
|
|
86
|
+
// (a) role gate — read-only workers must NOT plant plan records;
|
|
87
|
+
// (b) ownership — foreign-session runs require explicit force;
|
|
88
|
+
// (c) locked read-modify-write + fail-closed tool error (never a throw
|
|
89
|
+
// into the caller, never a lost update vs concurrent manifest writers).
|
|
90
|
+
const role = currentCrewRole();
|
|
91
|
+
if (role && permissionForRole(role) === "read_only") {
|
|
92
|
+
return result(
|
|
93
|
+
`Role '${role}' is read-only and cannot persist a plan record to a run.`,
|
|
94
|
+
{ action: "orchestrate", status: "error" },
|
|
95
|
+
true,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const runCwd = locateRunCwd(runId, ctx.cwd);
|
|
99
|
+
const loaded = runCwd ? loadRunManifestById(runCwd, runId) : undefined;
|
|
100
|
+
if (!loaded) {
|
|
101
|
+
return result(`runId not found: ${runId}`, { action: "orchestrate", status: "error" }, true);
|
|
102
|
+
}
|
|
103
|
+
const manifest = loaded.manifest;
|
|
104
|
+
if (typeof manifest.ownerSessionId === "string" && manifest.ownerSessionId !== ctx.sessionId && params.force !== true) {
|
|
105
|
+
return result(
|
|
106
|
+
`Run '${runId}' belongs to another session (owner ${manifest.ownerSessionId.slice(0, 8)}…). Pass force=true to persist anyway.`,
|
|
107
|
+
{ action: "orchestrate", status: "error", runId },
|
|
108
|
+
true,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const outcome = await withRunLock(manifest, async () => {
|
|
113
|
+
const fresh = loadRunManifestById(runCwd as string, runId); // in-lock consistent read
|
|
114
|
+
if (!fresh) return undefined;
|
|
115
|
+
const record = stepsToPlanRecord(steps, runId, { title: `Orchestrated: ${planPath}` });
|
|
116
|
+
appendPlanRevision(fresh.manifest, record);
|
|
117
|
+
saveRunManifest({
|
|
118
|
+
...fresh.manifest,
|
|
119
|
+
updatedAt: new Date().toISOString(),
|
|
120
|
+
plan: { id: record.id, version: record.version },
|
|
121
|
+
});
|
|
122
|
+
return record;
|
|
123
|
+
});
|
|
124
|
+
if (!outcome) return result(`runId not found: ${runId}`, { action: "orchestrate", status: "error" }, true);
|
|
125
|
+
persisted = { id: outcome.id, version: outcome.version, items: outcome.items.length };
|
|
126
|
+
planNote = `PlanRecord persisted to run ${runId}: v${outcome.version} (${outcome.items.length} item(s)) — team action='plans' runId='${runId}' to inspect.`;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
+
return result(`PlanRecord persistence failed: ${message}`, { action: "orchestrate", status: "error", runId }, true);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
70
133
|
const outputLines: string[] = [
|
|
71
134
|
`Plan: ${resolvedPath}`,
|
|
72
135
|
`Steps: ${steps.length}`,
|
|
136
|
+
planNote,
|
|
73
137
|
"",
|
|
74
138
|
"# Agent Chain Commands",
|
|
75
139
|
"",
|
|
@@ -86,6 +150,7 @@ export function handleOrchestrate(params: TeamToolParamsValue, ctx: TeamContext)
|
|
|
86
150
|
planPath: resolvedPath,
|
|
87
151
|
stepCount: steps.length,
|
|
88
152
|
commands,
|
|
153
|
+
persisted,
|
|
89
154
|
steps: steps.map((sqs) => ({
|
|
90
155
|
stepId: sqs.stepId,
|
|
91
156
|
tag: sqs.tag,
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T2/R4 (ADR-4 §7, erratum D-1) — `team action='plans'` handler.
|
|
3
|
+
*
|
|
4
|
+
* Read sub-actions (get/list/diff) render the revision list at
|
|
5
|
+
* `<stateRoot>/plans/plans.json` with DERIVED per-item progress. Mutating
|
|
6
|
+
* sub-actions (approve/reject) delegate to the existing api plan-approval ops
|
|
7
|
+
* (approve-plan / cancel-plan), which already dual-write manifest + record
|
|
8
|
+
* (ADR-4 §8) — one approval implementation, no drift.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* team action='plans' runId='<id>' → get (current rev)
|
|
12
|
+
* team action='plans' runId='<id>' config='{subAction:"get", rev:2}'
|
|
13
|
+
* team action='plans' runId='<id>' config='{subAction:"list"}'
|
|
14
|
+
* team action='plans' runId='<id>' config='{subAction:"diff", a:1, b:2}'
|
|
15
|
+
* team action='plans' runId='<id>' config='{subAction:"approve"}' | reject
|
|
16
|
+
*/
|
|
17
|
+
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
18
|
+
import { deriveItemProgress, getCurrentPlanRecord, loadPlanRecords } from "../../state/stores/plan-store.ts";
|
|
19
|
+
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
20
|
+
import type { PlanItemRecord, PlanRecord, TeamTaskState } from "../../state/types.ts";
|
|
21
|
+
import { handleTeamTool, locateRunCwd } from "../team-tool.ts";
|
|
22
|
+
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
23
|
+
import { result, type TeamContext } from "./context.ts";
|
|
24
|
+
|
|
25
|
+
const RUN_NOT_FOUND_HINT = "Use team action='list' to discover runs.";
|
|
26
|
+
|
|
27
|
+
function renderRecord(record: PlanRecord, tasks: TeamTaskState[]): string {
|
|
28
|
+
const progress = deriveItemProgress(record, tasks);
|
|
29
|
+
const lines: string[] = [];
|
|
30
|
+
lines.push(`Plan ${record.id} v${record.version}${record.revisionOf ? ` (revision of v${record.revisionOf.version})` : ""}`);
|
|
31
|
+
lines.push(`Title: ${record.title}`);
|
|
32
|
+
lines.push(`Run: ${record.runId} · created ${record.createdAt}${record.authorTaskId ? ` · author task ${record.authorTaskId}` : ""}`);
|
|
33
|
+
if (record.approval) {
|
|
34
|
+
lines.push(
|
|
35
|
+
`Approval: ${record.approval.status} (v${record.approval.planVersion}${record.approval.by ? ` by ${record.approval.by}` : ""} at ${record.approval.at})`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
lines.push("");
|
|
39
|
+
for (const phase of record.phases) {
|
|
40
|
+
lines.push(`[${phase.status}] Phase ${phase.id} — ${phase.title} (${phase.itemIds.length} item(s))`);
|
|
41
|
+
for (const itemId of phase.itemIds) {
|
|
42
|
+
const item = record.items.find((i: PlanItemRecord) => i.id === itemId);
|
|
43
|
+
if (!item) continue;
|
|
44
|
+
const p = progress.get(item.id);
|
|
45
|
+
const prog =
|
|
46
|
+
p && p.total > 0
|
|
47
|
+
? ` · ${p.done}/${p.total} done${p.failed ? `, ${p.failed} failed` : ""}${p.running ? `, ${p.running} running` : ""}`
|
|
48
|
+
: "";
|
|
49
|
+
lines.push(` - [${item.status}] ${item.id}: ${item.title}${prog}`);
|
|
50
|
+
if (item.taskIds.length > 0) lines.push(` tasks: ${item.taskIds.join(", ")}`);
|
|
51
|
+
if (item.acceptance.length > 0) lines.push(` acceptance: ${item.acceptance.join("; ")}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PlanDiff {
|
|
58
|
+
added: string[];
|
|
59
|
+
removed: string[];
|
|
60
|
+
dropped: string[];
|
|
61
|
+
statusChanged: Array<{ id: string; from: string; to: string }>;
|
|
62
|
+
retitled: Array<{ id: string; from: string; to: string }>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function diffRecords(a: PlanRecord, b: PlanRecord): PlanDiff {
|
|
66
|
+
const aById = new Map(a.items.map((i) => [i.id, i]));
|
|
67
|
+
const bById = new Map(b.items.map((i) => [i.id, i]));
|
|
68
|
+
const diff: PlanDiff = { added: [], removed: [], dropped: [], statusChanged: [], retitled: [] };
|
|
69
|
+
for (const [id, item] of bById) {
|
|
70
|
+
const prior = aById.get(id);
|
|
71
|
+
if (!prior) {
|
|
72
|
+
diff.added.push(id);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (prior.status !== item.status) diff.statusChanged.push({ id, from: prior.status, to: item.status });
|
|
76
|
+
if (prior.title !== item.title) diff.retitled.push({ id, from: prior.title, to: item.title });
|
|
77
|
+
if (item.status === "dropped" && prior.status !== "dropped") diff.dropped.push(id);
|
|
78
|
+
}
|
|
79
|
+
for (const id of aById.keys()) if (!bById.has(id)) diff.removed.push(id);
|
|
80
|
+
return diff;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderDiff(a: PlanRecord, b: PlanRecord): string {
|
|
84
|
+
const diff = diffRecords(a, b);
|
|
85
|
+
const lines = [
|
|
86
|
+
`Diff v${a.version} → v${b.version} (${a.id})`,
|
|
87
|
+
` added: ${diff.added.length ? diff.added.join(", ") : "—"}`,
|
|
88
|
+
` removed: ${diff.removed.length ? diff.removed.join(", ") : "—"}`,
|
|
89
|
+
` dropped: ${diff.dropped.length ? diff.dropped.join(", ") : "—"}`,
|
|
90
|
+
` status change: ${diff.statusChanged.length ? diff.statusChanged.map((s) => `${s.id} ${s.from}→${s.to}`).join(", ") : "—"}`,
|
|
91
|
+
` retitled: ${diff.retitled.length ? diff.retitled.map((s) => `${s.id}`).join(", ") : "—"}`,
|
|
92
|
+
];
|
|
93
|
+
if (diff.added.length + diff.removed.length + diff.dropped.length + diff.statusChanged.length + diff.retitled.length === 0) {
|
|
94
|
+
lines.push(" (no item-level changes)");
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function handlePlans(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
100
|
+
const subAction = (typeof params.subAction === "string" && params.subAction.trim() ? params.subAction.trim() : "get") as
|
|
101
|
+
| "get"
|
|
102
|
+
| "list"
|
|
103
|
+
| "diff"
|
|
104
|
+
| "approve"
|
|
105
|
+
| "reject";
|
|
106
|
+
const runId = typeof params.runId === "string" && params.runId.trim() ? params.runId.trim() : "";
|
|
107
|
+
if (!runId) return result("plans requires runId.", { action: "plans", status: "error" }, true);
|
|
108
|
+
const runCwd = locateRunCwd(runId, ctx.cwd);
|
|
109
|
+
const loaded = runCwd ? loadRunManifestById(runCwd, runId) : undefined;
|
|
110
|
+
if (!loaded) return result(`Run '${runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "plans", status: "error" }, true);
|
|
111
|
+
const { manifest, tasks } = loaded;
|
|
112
|
+
|
|
113
|
+
// Mutating sub-actions delegate to the api plan-approval ops (single
|
|
114
|
+
// implementation of the dual-write + permission checks).
|
|
115
|
+
if (subAction === "approve" || subAction === "reject") {
|
|
116
|
+
return await handleTeamTool(
|
|
117
|
+
{
|
|
118
|
+
action: "api",
|
|
119
|
+
runId,
|
|
120
|
+
config: { operation: subAction === "approve" ? "approve-plan" : "cancel-plan" },
|
|
121
|
+
},
|
|
122
|
+
// Forward the FULL caller context (sessionId etc.) so the delegated ops
|
|
123
|
+
// can enforce ownership checks now or in a future train (review S3) —
|
|
124
|
+
// only the cwd is overridden to the owning run's location.
|
|
125
|
+
{ ...ctx, cwd: runCwd ?? ctx.cwd },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const revisions = loadPlanRecords(manifest);
|
|
130
|
+
if (revisions.length === 0) {
|
|
131
|
+
// ADR-4 §2 fallback: pre-v2 run — degrade gracefully, manifest gate info.
|
|
132
|
+
const gate = manifest.planApproval;
|
|
133
|
+
return result(
|
|
134
|
+
`Run '${runId}' has no plan record (pre-v2 run).${gate ? ` Manifest gate: ${gate.status}${gate.required ? " (required)" : ""}.` : ""}`,
|
|
135
|
+
{ action: "plans", status: "ok", runId },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (subAction === "list") {
|
|
140
|
+
const lines = revisions.map(
|
|
141
|
+
(r) =>
|
|
142
|
+
`v${r.version} · ${r.createdAt} · ${r.items.length} item(s), ${r.items.filter((i) => i.status === "dropped").length} dropped · ${r.title}` +
|
|
143
|
+
(r.approval ? ` · approval ${r.approval.status}` : ""),
|
|
144
|
+
);
|
|
145
|
+
const current = manifest.plan ? ` (current: v${manifest.plan.version})` : " (current: highest — no manifest pointer)";
|
|
146
|
+
return result(`Plan revisions for ${runId}${current}:\n${lines.join("\n")}`, {
|
|
147
|
+
action: "plans",
|
|
148
|
+
status: "ok",
|
|
149
|
+
runId,
|
|
150
|
+
data: { revisions: revisions.length, current: manifest.plan?.version ?? revisions[revisions.length - 1]?.version },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (subAction === "diff") {
|
|
155
|
+
const a = typeof params.a === "number" ? params.a : Number(params.a);
|
|
156
|
+
const b = typeof params.b === "number" ? params.b : Number(params.b);
|
|
157
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
|
158
|
+
return result(
|
|
159
|
+
"diff requires numeric revision params: config='{subAction:\"diff\", a:1, b:2}'.",
|
|
160
|
+
{ action: "plans", status: "error" },
|
|
161
|
+
true,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const recA = revisions.find((r) => r.version === a);
|
|
165
|
+
const recB = revisions.find((r) => r.version === b);
|
|
166
|
+
if (!recA || !recB) {
|
|
167
|
+
return result(
|
|
168
|
+
`Revision(s) not found: available versions are ${revisions.map((r) => `v${r.version}`).join(", ")}.`,
|
|
169
|
+
{ action: "plans", status: "error" },
|
|
170
|
+
true,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return result(renderDiff(recA, recB), { action: "plans", status: "ok", runId, data: { a, b } });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// get (default)
|
|
177
|
+
const rev = params.rev === undefined ? undefined : Number(params.rev);
|
|
178
|
+
const record = rev === undefined || Number.isNaN(rev) ? getCurrentPlanRecord(manifest) : revisions.find((r) => r.version === rev);
|
|
179
|
+
if (!record) {
|
|
180
|
+
return result(
|
|
181
|
+
`Revision v${rev} not found: available versions are ${revisions.map((r) => `v${r.version}`).join(", ")}.`,
|
|
182
|
+
{ action: "plans", status: "error" },
|
|
183
|
+
true,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return result(renderRecord(record, tasks), {
|
|
187
|
+
action: "plans",
|
|
188
|
+
status: "ok",
|
|
189
|
+
runId,
|
|
190
|
+
data: { planId: record.id, version: record.version, items: record.items.length },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { renderAskAnswer } from "../../prompt/prompt-runtime.ts";
|
|
1
2
|
import { readCrewAgents, recordFromTask, saveCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
3
|
+
import { isWaitingWorkerAlive } from "../../runtime/dispatch-batch.ts";
|
|
2
4
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
3
5
|
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
4
|
-
import { appendMailboxMessage, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
|
|
6
|
+
import { appendMailboxMessage, readMailbox, updateMailboxMessageReply } from "../../state/coordination/mailbox.ts";
|
|
5
7
|
import { appendEventAsync } from "../../state/event-log/event-log.ts";
|
|
6
|
-
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
8
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
9
|
+
import type { TeamTaskState } from "../../state/types.ts";
|
|
7
10
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
8
11
|
import { locateRunCwd } from "../team-tool.ts";
|
|
9
12
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
@@ -11,10 +14,61 @@ import { result, type TeamContext } from "./context.ts";
|
|
|
11
14
|
import { paramRequired } from "./param-error.ts";
|
|
12
15
|
import { RUN_NOT_FOUND_HINT } from "./run-not-found.ts";
|
|
13
16
|
|
|
17
|
+
/** WP-2/R2 (ADR-0 item 8/security P1-4): fence the injected ask answer as
|
|
18
|
+
* untrusted when it is replayed into the next dispatch prompt via the
|
|
19
|
+
* `pendingSteers` cross-attempt channel. The mailbox is a same-uid
|
|
20
|
+
* unauthenticated channel — raw answer text is never injected without the
|
|
21
|
+
* dependency-context fence. */
|
|
22
|
+
/** Re-queue a waiting task for durable scheduler resume. Optionally carries an
|
|
23
|
+
* injected note (the fenced ask answer / timeout note) on the pendingSteers
|
|
24
|
+
* cross-attempt channel — child-executor re-appends pendingSteers into the
|
|
25
|
+
* next worker incarnation's steering file, and registerLiveAgent drains them
|
|
26
|
+
* for the live-session runtime. The park marker (`waiting`) is cleared: a
|
|
27
|
+
* requeued task must not stay parked (mirrors applyRecoveryPlan discipline). */
|
|
28
|
+
function requeueWaitingTask(tasks: TeamTaskState[], taskId: string, message: string, injection?: string): TeamTaskState[] {
|
|
29
|
+
return tasks.map((task) => {
|
|
30
|
+
if (task.id !== taskId) return task;
|
|
31
|
+
return {
|
|
32
|
+
...task,
|
|
33
|
+
status: "queued" as const,
|
|
34
|
+
startedAt: undefined,
|
|
35
|
+
finishedAt: undefined,
|
|
36
|
+
error: undefined,
|
|
37
|
+
waiting: undefined,
|
|
38
|
+
adaptive: {
|
|
39
|
+
...task.adaptive,
|
|
40
|
+
phase: "resumed",
|
|
41
|
+
task: message || task.adaptive?.task || "",
|
|
42
|
+
},
|
|
43
|
+
...(injection ? { pendingSteers: [...(task.pendingSteers ?? []), injection] } : {}),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
14
48
|
/**
|
|
15
|
-
* Handle `respond` action:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
49
|
+
* Handle `respond` action: answer a waiting (parked) task's question.
|
|
50
|
+
*
|
|
51
|
+
* WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask.md item 8):
|
|
52
|
+
* liveness discrimination replaces the old blanket re-queue of ALL waiting
|
|
53
|
+
* tasks.
|
|
54
|
+
* - ALIVE (heartbeat last-beat within the 60s gradient-stale window, or a
|
|
55
|
+
* live in-memory session handle) → append a mailbox `kind:"response"`
|
|
56
|
+
* entry carrying the task's `waiting.questionId` (from leader, to the task)
|
|
57
|
+
* and LEAVE `task.waiting` in place: the parked `ask` tool polls the task
|
|
58
|
+
* mailbox stream, picks the response up and flips waiting→running via its
|
|
59
|
+
* own terminal report.
|
|
60
|
+
* - DEAD → re-queue the task AND inject the fenced answer into the next
|
|
61
|
+
* dispatch prompt (pendingSteers cross-attempt channel). No questionId-
|
|
62
|
+
* tagged mailbox response is written.
|
|
63
|
+
* - EXACTLY-ONE-DISPATCH GUARD: a response for a given questionId dispatches
|
|
64
|
+
* via mailbox-write OR requeue+inject — never both — and is idempotent per
|
|
65
|
+
* questionId. The branch is atomic under the run lock (check + write inside
|
|
66
|
+
* one withRunLockSync + fresh-reload critical section); the requeue path
|
|
67
|
+
* clears `waiting` so a second respond finds no waiting task (no-op).
|
|
68
|
+
* - Legacy waiting parks without `task.waiting` (pre-v2 records) keep the old
|
|
69
|
+
* mailbox + re-queue resume behavior — there is no parked tool to pick a
|
|
70
|
+
* questionId-tagged response up.
|
|
71
|
+
* - `ask.answered` is emitted on both discrimination paths.
|
|
18
72
|
*/
|
|
19
73
|
export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
20
74
|
if (!params.runId)
|
|
@@ -80,24 +134,78 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
80
134
|
);
|
|
81
135
|
}
|
|
82
136
|
|
|
83
|
-
const resumed = new Set(targetTasks.map((t) => t.id));
|
|
84
137
|
const mailboxIds: string[] = [];
|
|
138
|
+
/** ALIVE parks: response delivered via the mailbox stream the parked ask tool polls. */
|
|
139
|
+
const answeredIds: string[] = [];
|
|
140
|
+
/** DEAD parks (and legacy no-question parks): re-queued for durable scheduler resume. */
|
|
141
|
+
const requeuedIds: string[] = [];
|
|
142
|
+
/** Exactly-once no-ops: a mailbox response already exists for the questionId. */
|
|
143
|
+
const noopIds: string[] = [];
|
|
144
|
+
const answeredEvents: Array<{ taskId: string; questionId: string; delivery: "mailbox" | "requeue" }> = [];
|
|
145
|
+
let updatedTasks = fresh.tasks;
|
|
146
|
+
|
|
85
147
|
for (const task of targetTasks) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
148
|
+
const questionId = task.waiting?.questionId;
|
|
149
|
+
if (!questionId) {
|
|
150
|
+
// Legacy pre-v2 waiting park (no ask question): keep the old
|
|
151
|
+
// mailbox + re-queue resume behavior verbatim.
|
|
152
|
+
const mailbox = appendMailboxMessage(fresh.manifest, {
|
|
153
|
+
direction: "inbox",
|
|
154
|
+
from: "leader",
|
|
155
|
+
to: task.id,
|
|
156
|
+
taskId: task.id,
|
|
157
|
+
body: message || "(resume)",
|
|
158
|
+
kind: "response",
|
|
159
|
+
priority: "normal",
|
|
160
|
+
deliveryMode: "next_turn",
|
|
161
|
+
data: { action: "respond", kind: "response" },
|
|
162
|
+
replyTo: params.replyTo,
|
|
163
|
+
replyFrom: params.replyFrom,
|
|
164
|
+
replyDeadline: params.replyDeadline,
|
|
165
|
+
});
|
|
166
|
+
mailboxIds.push(mailbox.id);
|
|
167
|
+
updatedTasks = requeueWaitingTask(updatedTasks, task.id, message);
|
|
168
|
+
requeuedIds.push(task.id);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// EXACTLY-ONE-DISPATCH GUARD (idempotent per questionId): if a
|
|
172
|
+
// mailbox response for this questionId already exists, do nothing —
|
|
173
|
+
// never write a second response and never requeue on top of it.
|
|
174
|
+
// Atomic: the scan and the write below share this run-lock section.
|
|
175
|
+
if (readMailbox(fresh.manifest, "inbox", task.id, "response").some((m) => m.questionId === questionId)) {
|
|
176
|
+
noopIds.push(task.id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (isWaitingWorkerAlive(task)) {
|
|
180
|
+
// ALIVE: deliver via the task mailbox stream the parked ask tool
|
|
181
|
+
// polls (≤500ms). The park stays — the tool's terminal report
|
|
182
|
+
// flips waiting→running; root-side status must NOT change here.
|
|
183
|
+
const mailbox = appendMailboxMessage(fresh.manifest, {
|
|
184
|
+
direction: "inbox",
|
|
185
|
+
from: "leader",
|
|
186
|
+
to: task.id,
|
|
187
|
+
taskId: task.id,
|
|
188
|
+
body: message || "(resume)",
|
|
189
|
+
kind: "response",
|
|
190
|
+
priority: "normal",
|
|
191
|
+
deliveryMode: "next_turn",
|
|
192
|
+
questionId,
|
|
193
|
+
data: { action: "respond", kind: "response", questionId },
|
|
194
|
+
replyTo: params.replyTo,
|
|
195
|
+
replyFrom: params.replyFrom,
|
|
196
|
+
replyDeadline: params.replyDeadline,
|
|
197
|
+
});
|
|
198
|
+
mailboxIds.push(mailbox.id);
|
|
199
|
+
answeredIds.push(task.id);
|
|
200
|
+
answeredEvents.push({ taskId: task.id, questionId, delivery: "mailbox" });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// DEAD: re-queue AND inject the fenced answer into the next dispatch
|
|
204
|
+
// prompt. No questionId-tagged mailbox response is written — the
|
|
205
|
+
// parked poller is gone; delivery rides the requeue instead.
|
|
206
|
+
updatedTasks = requeueWaitingTask(updatedTasks, task.id, message, renderAskAnswer(questionId, message));
|
|
207
|
+
requeuedIds.push(task.id);
|
|
208
|
+
answeredEvents.push({ taskId: task.id, questionId, delivery: "requeue" });
|
|
101
209
|
}
|
|
102
210
|
|
|
103
211
|
// If this respond includes a replyTo, update the original message with reply metadata.
|
|
@@ -105,40 +213,53 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
105
213
|
updateMailboxMessageReply(fresh.manifest, params.replyTo, message || "(resume)");
|
|
106
214
|
}
|
|
107
215
|
|
|
108
|
-
// Re-queue waiting tasks so durable scheduler/resume can pick them up again.
|
|
109
|
-
const updatedTasks = fresh.tasks.map((task) => {
|
|
110
|
-
if (!resumed.has(task.id)) return task;
|
|
111
|
-
return {
|
|
112
|
-
...task,
|
|
113
|
-
status: "queued" as const,
|
|
114
|
-
startedAt: undefined,
|
|
115
|
-
finishedAt: undefined,
|
|
116
|
-
error: undefined,
|
|
117
|
-
adaptive: {
|
|
118
|
-
...task.adaptive,
|
|
119
|
-
phase: "resumed",
|
|
120
|
-
task: message || task.adaptive?.task || "",
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
saveRunTasks(fresh.manifest, updatedTasks);
|
|
126
216
|
let manifest = fresh.manifest;
|
|
127
|
-
if (
|
|
128
|
-
manifest
|
|
129
|
-
manifest.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
217
|
+
if (requeuedIds.length > 0) {
|
|
218
|
+
saveRunTasks(fresh.manifest, updatedTasks);
|
|
219
|
+
// WP-2 review round 1 (P3): clear manifest.waitState when its park was
|
|
220
|
+
// requeued — mirror the broker's clearWaitState guard (taskId match),
|
|
221
|
+
// else the stale pointer shields the run from staleness repair for
|
|
222
|
+
// the full 24h TTL and leaves a stale marker in the manifest/UI.
|
|
223
|
+
if (manifest.waitState && requeuedIds.includes(manifest.waitState.taskId)) {
|
|
224
|
+
manifest = { ...manifest, waitState: undefined, updatedAt: new Date().toISOString() };
|
|
225
|
+
saveRunManifest(manifest);
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
manifest.status === "blocked" ||
|
|
229
|
+
manifest.status === "completed" ||
|
|
230
|
+
manifest.status === "failed" ||
|
|
231
|
+
manifest.status === "cancelled"
|
|
232
|
+
) {
|
|
233
|
+
manifest = updateRunStatus(manifest, "running", `Resumed ${requeuedIds.length} waiting task(s).`);
|
|
234
|
+
}
|
|
134
235
|
}
|
|
135
|
-
|
|
236
|
+
// ask.answered on BOTH discrimination paths (ADR-0 item 8).
|
|
237
|
+
for (const answered of answeredEvents) {
|
|
136
238
|
// H1 (2026-08-10): handleRespond is a SYNC function inside a sync
|
|
137
239
|
// run-lock callback — cannot await; fire-and-forget async.
|
|
240
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
241
|
+
type: "ask.answered",
|
|
242
|
+
runId: manifest.runId,
|
|
243
|
+
taskId: answered.taskId,
|
|
244
|
+
message: `Question ${answered.questionId} answered (${
|
|
245
|
+
answered.delivery === "mailbox"
|
|
246
|
+
? "worker alive — parked ask tool picks up the mailbox response"
|
|
247
|
+
: "worker dead — task requeued with the answer injected"
|
|
248
|
+
}).`,
|
|
249
|
+
data: { questionId: answered.questionId, delivery: answered.delivery },
|
|
250
|
+
}).catch((error) =>
|
|
251
|
+
logInternalError(
|
|
252
|
+
"respond.ask-answered-event",
|
|
253
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
254
|
+
`runId=${manifest.runId}`,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
for (const requeuedId of requeuedIds) {
|
|
138
259
|
void appendEventAsync(manifest.eventsPath, {
|
|
139
260
|
type: "task.resumed",
|
|
140
261
|
runId: manifest.runId,
|
|
141
|
-
taskId,
|
|
262
|
+
taskId: requeuedId,
|
|
142
263
|
message: message || "Task re-queued after respond.",
|
|
143
264
|
data: { mailboxIds },
|
|
144
265
|
}).catch((error) =>
|
|
@@ -149,24 +270,35 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
149
270
|
),
|
|
150
271
|
);
|
|
151
272
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
273
|
+
if (requeuedIds.length > 0) {
|
|
274
|
+
try {
|
|
275
|
+
const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
|
|
276
|
+
saveCrewAgents(
|
|
277
|
+
fresh.manifest,
|
|
278
|
+
updatedTasks
|
|
279
|
+
.filter((task) => requeuedIds.includes(task.id))
|
|
280
|
+
.map((task) => recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process")),
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
logInternalError("team-tool.handleRespond.crewAgents", error, `runId=${fresh.manifest.runId}`);
|
|
284
|
+
}
|
|
160
285
|
}
|
|
161
286
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
287
|
+
const summaryParts: string[] = [];
|
|
288
|
+
if (answeredIds.length > 0) summaryParts.push(`answered via mailbox (worker alive): ${answeredIds.join(", ")}`);
|
|
289
|
+
if (requeuedIds.length > 0) summaryParts.push(`requeued with injected answer (worker dead): ${requeuedIds.join(", ")}`);
|
|
290
|
+
if (noopIds.length > 0) summaryParts.push(`already answered, no-op: ${noopIds.join(", ")}`);
|
|
291
|
+
return result(
|
|
292
|
+
`Responded to ${targetTasks.length} waiting task(s). ${summaryParts.join("; ")}. Message: ${message || "(no message)"}`,
|
|
293
|
+
{
|
|
294
|
+
action: "respond",
|
|
295
|
+
status: "ok",
|
|
296
|
+
runId: fresh.manifest.runId,
|
|
297
|
+
resumedIds: requeuedIds,
|
|
298
|
+
mailboxIds,
|
|
299
|
+
intent: `responding to ${targetTasks.map((t) => t.id).join(", ")} in ${fresh.manifest.runId}`,
|
|
300
|
+
data: { answeredIds, noopIds },
|
|
301
|
+
},
|
|
302
|
+
);
|
|
171
303
|
});
|
|
172
304
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { PiTeamsConfig } from "../../config/config.ts";
|
|
2
2
|
import { loadConfig } from "../../config/config.ts";
|
|
3
|
+
import { isSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
|
|
4
|
+
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
3
5
|
import type { TeamContext } from "./context.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -55,16 +57,46 @@ export function resolveRunDeadline<T extends object>(
|
|
|
55
57
|
|
|
56
58
|
const controller = new AbortController();
|
|
57
59
|
// Propagate caller abort (ctx.signal) to the shared controller.
|
|
60
|
+
// R6-F2 (W2/RC-03 consistency): named handler instead of an anonymous
|
|
61
|
+
// { once: true } closure. resolveRunDeadline returns immediately (there is
|
|
62
|
+
// no run-lifetime finally scope inside this module — callers in run.ts only
|
|
63
|
+
// clearTimeout the timer), so removal is wired to the deadline controller's
|
|
64
|
+
// own abort: once the run controller aborts for ANY cause (deadline timer
|
|
65
|
+
// fire, caller-signal propagation, or an explicit controller.abort() from a
|
|
66
|
+
// linked path such as startForegroundRun), the propagation listener is dead
|
|
67
|
+
// weight on ctx.signal and is removed. Behavior-neutral: if the caller's
|
|
68
|
+
// signal fired afterwards, the listener would only call abort() on an
|
|
69
|
+
// already-aborted controller — a no-op.
|
|
58
70
|
if (ctx.signal) {
|
|
59
|
-
if (ctx.signal.aborted) controller.
|
|
60
|
-
else
|
|
71
|
+
if (ctx.signal.aborted) safeAbort(controller, "run-deadline.caller-preaborted");
|
|
72
|
+
else {
|
|
73
|
+
const callerSignal = ctx.signal;
|
|
74
|
+
// The deadline signal is passed down to run executors that may spawn
|
|
75
|
+
// children with it; aborting after a child exited throws AbortError
|
|
76
|
+
// from Node's child_process listener — never let that escape a
|
|
77
|
+
// pi event handler (safe-abort).
|
|
78
|
+
const onCallerAbort = () => {
|
|
79
|
+
// Regression: switching sessions (resume/new/fork — including
|
|
80
|
+
// /crew-view) tears the current turn down via session.abort(),
|
|
81
|
+
// firing the tool-call abort while the run is still forming. A
|
|
82
|
+
// foreground run must survive session switches (P0 — it shares
|
|
83
|
+
// the process), so a teardown abort must NOT cancel it. Only a
|
|
84
|
+
// genuine caller abort OUTSIDE a session switch propagates.
|
|
85
|
+
if (isSessionSwitchInFlight()) return;
|
|
86
|
+
safeAbort(controller, "run-deadline.caller-abort");
|
|
87
|
+
};
|
|
88
|
+
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
|
|
89
|
+
controller.signal.addEventListener("abort", () => callerSignal.removeEventListener("abort", onCallerAbort), { once: true });
|
|
90
|
+
}
|
|
61
91
|
}
|
|
62
92
|
// Arm the deadline timer (unref'd so it never blocks process exit).
|
|
63
93
|
// RC-02: expose the timer so callers can clearTimeout on normal completion —
|
|
64
94
|
// otherwise every run leaves a dangling 1h timer retaining ctx/params in closure.
|
|
95
|
+
// Same abort-after-exit guard as above: the timer may fire after workers
|
|
96
|
+
// already exited and their spawn-signal listeners are gone — Node can throw.
|
|
65
97
|
let timer: NodeJS.Timeout | undefined;
|
|
66
98
|
if (deadlineMs > 0) {
|
|
67
|
-
timer = setTimeout(() => controller.
|
|
99
|
+
timer = setTimeout(() => safeAbort(controller, "run-deadline.timer"), deadlineMs);
|
|
68
100
|
timer.unref?.();
|
|
69
101
|
}
|
|
70
102
|
return { signal: controller.signal, deadlineMs, controller, timer };
|