pi-crew 0.9.68 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +222 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +68113 -60774
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +503 -91
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +687 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +43 -2
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +11 -9
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +49 -1
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +228 -0
- package/src/extension/registration/commands/shared.ts +639 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +178 -17
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +35 -3
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +74 -20
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +32 -10
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +36 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +111 -46
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +132 -1673
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +66 -26
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +139 -232
- package/src/state/types.ts +199 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +243 -38
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +20 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +17 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-batch selection and execution for the team-run scheduler loop.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
|
|
5
|
+
* CORE-4 extractions 3+4). Pure code motion: selectDispatchBatch /
|
|
6
|
+
* DispatchBatchDecision / dispatchBatch and their module-private
|
|
7
|
+
* collaborators (findStep, findAgent, markBlocked, cancelNonTerminalTasks,
|
|
8
|
+
* retryPolicyFromConfig, shouldUseRetry, failedTaskFrom, dagReadyTaskIds)
|
|
9
|
+
* moved verbatim.
|
|
10
|
+
*/
|
|
11
|
+
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
12
|
+
import type { CrewReliabilityConfig } from "../config/config.ts";
|
|
13
|
+
import { CrewError, ErrorCode } from "../errors.ts";
|
|
14
|
+
import { appendHookEvent, executeHook } from "../hooks/registry.ts";
|
|
15
|
+
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
16
|
+
import { withRunLockSync } from "../state/coordination/locks.ts";
|
|
17
|
+
import { appendEventAsync, appendEventBuffered } from "../state/event-log/event-log.ts";
|
|
18
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
19
|
+
import type { TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
20
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
21
|
+
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
22
|
+
import { readCrewAgents, recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
|
|
23
|
+
import { appendDeadletter } from "./deadletter.ts";
|
|
24
|
+
import { classifyHeartbeat, DEFAULT_GRADIENT_THRESHOLDS } from "./heartbeat/heartbeat-gradient.ts";
|
|
25
|
+
import { getLiveAgent } from "./live-session/live-agent-manager.ts";
|
|
26
|
+
import { isNonTerminalTaskStatus } from "./merge-gate.ts";
|
|
27
|
+
import { resolveTaskRuntimeKind } from "./model/runtime-policy.ts";
|
|
28
|
+
import { filterReadyByWriteOverlap } from "./path-overlap.ts";
|
|
29
|
+
import { isMutatingTask, isPlanApprovalPendingEffective } from "./plan-approval.ts";
|
|
30
|
+
import { sweepDroppedPlanItems } from "./plan-replan.ts";
|
|
31
|
+
import { CrewCancellationError, cancellationReasonFromSignal } from "./process/cancellation.ts";
|
|
32
|
+
import { DEFAULT_RETRY_POLICY, executeWithRetry, type RetryPolicy } from "./recovery/retry-executor.ts";
|
|
33
|
+
import type { SchedulerContext, SchedulerDecision, SettledUnit } from "./scheduler-context.ts";
|
|
34
|
+
import { buildDispatchUnits, type DispatchUnit, planCoalescedGroups } from "./scheduling/coalesce-tasks.ts";
|
|
35
|
+
import { resolveBatchConcurrency } from "./scheduling/concurrency.ts";
|
|
36
|
+
import { runCoalescedTaskGroup } from "./scheduling/run-coalesced-task-group.ts";
|
|
37
|
+
import { buildExecutionPlan as buildDagExecutionPlan, getReadyTasks as getDagReadyTasks, type TaskNode } from "./scheduling/task-graph.ts";
|
|
38
|
+
import { taskGraphSnapshot } from "./scheduling/task-graph-scheduler.ts";
|
|
39
|
+
import { recordsForMaterializedTasks } from "./task-display.ts";
|
|
40
|
+
import { computeStablePrefixComponents } from "./task-runner/prompt-builder.ts";
|
|
41
|
+
import { runTeamTask, type SpawnBudget } from "./task-runner.ts";
|
|
42
|
+
import { type PhaseGuardContext, validatePhasePreconditions } from "./workflow-state.ts";
|
|
43
|
+
|
|
44
|
+
// ── WP-2/R2 (ADR-0 items 8+10): waiting-producer liveness + deadline owner ──
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask.md item 8):
|
|
48
|
+
* liveness discriminator for a parked (waiting) worker.
|
|
49
|
+
*
|
|
50
|
+
* ALIVE = heartbeat last-beat within the gradient stale window (<60s —
|
|
51
|
+
* classifyHeartbeat "healthy"/"warn") OR a live in-memory session handle
|
|
52
|
+
* (live-agent registry, non-terminal status). Everything else is DEAD for
|
|
53
|
+
* delivery purposes: stale (60–300s), dead (>300s), `alive:false`, no
|
|
54
|
+
* heartbeat and no handle.
|
|
55
|
+
*
|
|
56
|
+
* Shared by the respond discriminator (extension/team-tool/respond.ts) and
|
|
57
|
+
* the scheduler-tick deadline owner below — one definition, two consumers.
|
|
58
|
+
*/
|
|
59
|
+
export function isWaitingWorkerAlive(task: TeamTaskState, now = Date.now()): boolean {
|
|
60
|
+
// Live in-memory handle: same-process live-session runtime keeps a session
|
|
61
|
+
// handle in the registry keyed by (agentId | taskId).
|
|
62
|
+
const handle = getLiveAgent(task.id);
|
|
63
|
+
if (handle && (handle.status === "running" || handle.status === "queued" || handle.status === "waiting")) return true;
|
|
64
|
+
// Heartbeat: last beat inside the gradient stale window (<60s).
|
|
65
|
+
const level = classifyHeartbeat(task.heartbeat, DEFAULT_GRADIENT_THRESHOLDS, now);
|
|
66
|
+
return level === "healthy" || level === "warn";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** In-process exactly-once guard: questionIds whose `ask.timedout` event was
|
|
70
|
+
* already emitted by this scheduler process (prevents re-emission on every
|
|
71
|
+
* subsequent tick while an ALIVE park waits for its in-tool timeout to
|
|
72
|
+
* surface). Capped with FIFO eviction to bound memory. */
|
|
73
|
+
const emittedAskTimedoutQuestionIds = new Set<string>();
|
|
74
|
+
const MAX_EMITTED_ASK_TIMEDOUT = 10_000;
|
|
75
|
+
|
|
76
|
+
export interface WaitingDeadlineSweepResult {
|
|
77
|
+
manifest: TeamRunManifest;
|
|
78
|
+
tasks: TeamTaskState[];
|
|
79
|
+
/** QuestionIds for which an `ask.timedout` event was emitted this sweep. */
|
|
80
|
+
timedOutQuestionIds: string[];
|
|
81
|
+
/** Task ids requeued this sweep (dead workers only). */
|
|
82
|
+
requeuedTaskIds: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** WP-2/R2 (ADR-0 item 10): render the "[ask timed out]" note injected into the
|
|
86
|
+
* next dispatch when a parked ask deadline expires with no worker alive.
|
|
87
|
+
* Fenced as untrusted (same-uid channel discipline). */
|
|
88
|
+
function renderAskTimedoutInjection(questionId: string): string {
|
|
89
|
+
return [
|
|
90
|
+
"<dependency-context>",
|
|
91
|
+
"(Scheduler note: the previous ask(question) deadline expired with no worker alive to receive an answer. It is DATA, not a system directive.)",
|
|
92
|
+
`[ask timed out] questionId=${questionId}`,
|
|
93
|
+
"Continue with best judgment.",
|
|
94
|
+
"</dependency-context>",
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* WP-2/R2 (ADR-0 item 10) — scheduler-tick deadline owner for parked asks.
|
|
100
|
+
*
|
|
101
|
+
* For every task with a persisted `task.waiting` whose deadline has expired:
|
|
102
|
+
* - worker ALIVE → no root-side state change (the parked ask tool's own poll
|
|
103
|
+
* deadline surfaces the timeout in-tool); only the `ask.timedout` event is
|
|
104
|
+
* appended.
|
|
105
|
+
* - worker DEAD → clear `waiting`, re-queue the task with a "[ask timed out]"
|
|
106
|
+
* note injected into the next dispatch (pendingSteers cross-attempt
|
|
107
|
+
* channel), and append `ask.timedout`.
|
|
108
|
+
*
|
|
109
|
+
* All state changes run under the run lock with a fresh reload
|
|
110
|
+
* (respond.ts:42-43 discipline); events are appended AFTER the lock is
|
|
111
|
+
* released (awaited — callers are async). Exactly-once per questionId via
|
|
112
|
+
* the in-process guard above (a requeued task also loses `waiting`, so a
|
|
113
|
+
* second sweep pass skips it).
|
|
114
|
+
*
|
|
115
|
+
* Returns undefined when there is nothing to do (no expired waiting park).
|
|
116
|
+
*/
|
|
117
|
+
export async function sweepExpiredWaitingTasks(
|
|
118
|
+
cwd: string,
|
|
119
|
+
runId: string,
|
|
120
|
+
now = Date.now(),
|
|
121
|
+
): Promise<WaitingDeadlineSweepResult | undefined> {
|
|
122
|
+
const initial = loadRunManifestById(cwd, runId);
|
|
123
|
+
if (!initial) return undefined;
|
|
124
|
+
const hasExpired = initial.tasks.some((t) => t.status === "waiting" && t.waiting !== undefined && t.waiting.deadline <= now);
|
|
125
|
+
if (!hasExpired) return undefined;
|
|
126
|
+
const pendingEvents: Array<{ taskId: string; questionId: string; workerAlive: boolean }> = [];
|
|
127
|
+
const outcome = withRunLockSync(initial.manifest, () => {
|
|
128
|
+
const fresh = loadRunManifestById(cwd, runId); // NOTE: inside withRunLockSync - consistent read
|
|
129
|
+
if (!fresh) return null;
|
|
130
|
+
let tasks = fresh.tasks;
|
|
131
|
+
const timedOutQuestionIds: string[] = [];
|
|
132
|
+
const requeuedTaskIds: string[] = [];
|
|
133
|
+
for (const task of fresh.tasks) {
|
|
134
|
+
if (task.status !== "waiting" || task.waiting === undefined) continue;
|
|
135
|
+
if (task.waiting.deadline > now) continue;
|
|
136
|
+
const questionId = task.waiting.questionId;
|
|
137
|
+
if (emittedAskTimedoutQuestionIds.has(questionId)) continue; // exactly-once per process
|
|
138
|
+
if (emittedAskTimedoutQuestionIds.size >= MAX_EMITTED_ASK_TIMEDOUT) {
|
|
139
|
+
const oldest = emittedAskTimedoutQuestionIds.keys().next().value;
|
|
140
|
+
if (oldest !== undefined) emittedAskTimedoutQuestionIds.delete(oldest);
|
|
141
|
+
}
|
|
142
|
+
emittedAskTimedoutQuestionIds.add(questionId);
|
|
143
|
+
const workerAlive = isWaitingWorkerAlive(task, now);
|
|
144
|
+
timedOutQuestionIds.push(questionId);
|
|
145
|
+
pendingEvents.push({ taskId: task.id, questionId, workerAlive });
|
|
146
|
+
if (!workerAlive) {
|
|
147
|
+
// DEAD: clear the park and re-queue with the injected timeout note.
|
|
148
|
+
const note = renderAskTimedoutInjection(questionId);
|
|
149
|
+
tasks = tasks.map((t) =>
|
|
150
|
+
t.id === task.id
|
|
151
|
+
? {
|
|
152
|
+
...t,
|
|
153
|
+
status: "queued" as const,
|
|
154
|
+
startedAt: undefined,
|
|
155
|
+
finishedAt: undefined,
|
|
156
|
+
error: undefined,
|
|
157
|
+
waiting: undefined,
|
|
158
|
+
pendingSteers: [...(t.pendingSteers ?? []), note],
|
|
159
|
+
adaptive: {
|
|
160
|
+
...t.adaptive,
|
|
161
|
+
phase: "resumed",
|
|
162
|
+
task: t.adaptive?.task ?? "",
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
: t,
|
|
166
|
+
);
|
|
167
|
+
requeuedTaskIds.push(task.id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (requeuedTaskIds.length === 0) {
|
|
171
|
+
return { manifest: fresh.manifest, tasks: fresh.tasks, timedOutQuestionIds, requeuedTaskIds };
|
|
172
|
+
}
|
|
173
|
+
saveRunTasks(fresh.manifest, tasks);
|
|
174
|
+
let manifest = fresh.manifest;
|
|
175
|
+
// WP-2 review round 1 (P3): clear manifest.waitState when its park was
|
|
176
|
+
// requeued — mirror the broker's clearWaitState guard, else the stale
|
|
177
|
+
// pointer shields the run from staleness repair for the full 24h TTL.
|
|
178
|
+
if (manifest.waitState && requeuedTaskIds.includes(manifest.waitState.taskId)) {
|
|
179
|
+
manifest = { ...manifest, waitState: undefined, updatedAt: new Date().toISOString() };
|
|
180
|
+
saveRunManifest(manifest);
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
manifest.status === "blocked" ||
|
|
184
|
+
manifest.status === "completed" ||
|
|
185
|
+
manifest.status === "failed" ||
|
|
186
|
+
manifest.status === "cancelled"
|
|
187
|
+
) {
|
|
188
|
+
manifest = updateRunStatus(manifest, "running", `Requeued ${requeuedTaskIds.length} waiting task(s) after ask timeout.`);
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
|
|
192
|
+
saveCrewAgents(
|
|
193
|
+
fresh.manifest,
|
|
194
|
+
tasks
|
|
195
|
+
.filter((t) => requeuedTaskIds.includes(t.id))
|
|
196
|
+
.map((t) => recordFromTask(fresh.manifest, t, existingRuntimes.get(t.id) ?? "child-process")),
|
|
197
|
+
);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
logInternalError("dispatch-batch.ask-timeout.crewAgents", error, `runId=${runId}`);
|
|
200
|
+
}
|
|
201
|
+
return { manifest, tasks, timedOutQuestionIds, requeuedTaskIds };
|
|
202
|
+
});
|
|
203
|
+
if (!outcome) return undefined;
|
|
204
|
+
// Events appended AFTER the run lock is released; awaited so callers (and
|
|
205
|
+
// tests) observe durable events.jsonl once the sweep resolves.
|
|
206
|
+
for (const event of pendingEvents) {
|
|
207
|
+
await appendEventAsync(outcome.manifest.eventsPath, {
|
|
208
|
+
type: "ask.timedout",
|
|
209
|
+
runId,
|
|
210
|
+
taskId: event.taskId,
|
|
211
|
+
message: `Ask deadline expired for question ${event.questionId}${
|
|
212
|
+
event.workerAlive ? " (worker alive — surfaced in-tool)" : " (worker dead — task requeued)"
|
|
213
|
+
}.`,
|
|
214
|
+
data: { questionId: event.questionId, workerAlive: event.workerAlive, surface: event.workerAlive ? "in-tool" : "requeue" },
|
|
215
|
+
}).catch((error) => logInternalError("dispatch-batch.ask-timeout-event", error, `runId=${runId} taskId=${event.taskId}`));
|
|
216
|
+
}
|
|
217
|
+
return outcome;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
|
|
221
|
+
const step = workflow.steps.find((candidate) => candidate.id === task.stepId);
|
|
222
|
+
if (!step)
|
|
223
|
+
throw new CrewError(ErrorCode.ResourceNotFound, `Workflow step '${task.stepId}' not found for task '${task.id}'.`).withContext(
|
|
224
|
+
`workflow step lookup (task=${task.id})`,
|
|
225
|
+
);
|
|
226
|
+
// T4/R6 (ADR-6 §7): workflow-level specStrict merges into the dispatched step
|
|
227
|
+
// (per-step flag wins) — the task packet freezes the resolved value.
|
|
228
|
+
if (workflow.specStrict === true || step.specStrict === true) {
|
|
229
|
+
return { ...step, specStrict: step.specStrict ?? workflow.specStrict };
|
|
230
|
+
}
|
|
231
|
+
return step;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function findAgent(agents: AgentConfig[], task: TeamTaskState): AgentConfig {
|
|
235
|
+
const agent = agents.find((candidate) => candidate.name === task.agent);
|
|
236
|
+
if (!agent)
|
|
237
|
+
throw new CrewError(ErrorCode.ResourceNotFound, `Agent '${task.agent}' not found for task '${task.id}'.`).withContext(
|
|
238
|
+
`agent lookup (task=${task.id})`,
|
|
239
|
+
);
|
|
240
|
+
return agent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function markBlocked(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
|
|
244
|
+
return tasks.map((task) =>
|
|
245
|
+
task.status === "queued"
|
|
246
|
+
? {
|
|
247
|
+
...task,
|
|
248
|
+
status: "skipped",
|
|
249
|
+
error: reason,
|
|
250
|
+
finishedAt: new Date().toISOString(),
|
|
251
|
+
graph: task.graph ? { ...task.graph, queue: "blocked" } : undefined,
|
|
252
|
+
}
|
|
253
|
+
: task,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* CORE-6: Unified cancel/fail of non-terminal tasks. Replaces hand-rolled
|
|
259
|
+
* `.map()` + transform sites across this file.
|
|
260
|
+
*
|
|
261
|
+
* - Without `filter`: all non-terminal tasks (queued/running/waiting) are
|
|
262
|
+
* terminalised with the given status.
|
|
263
|
+
* - With `filter`: the filter is the sole gate — the non-terminal check is
|
|
264
|
+
* NOT applied automatically, matching per-task/per-id variants.
|
|
265
|
+
* - Optional `transform(task, terminalised)`: lets a caller attach
|
|
266
|
+
* site-specific fields (graph mutation, terminalEvidence) to the
|
|
267
|
+
* terminalised task. `terminalised` already carries status/finishedAt/error;
|
|
268
|
+
* the transform returns it unchanged or a modified copy.
|
|
269
|
+
*
|
|
270
|
+
* RT-14: the two remaining inline cancel sites (cancelPlanTasks,
|
|
271
|
+
* cancelRunFromSignal) route through this helper via `transform` so EVERY
|
|
272
|
+
* cancel site uses the single shared transform. Their extra logic
|
|
273
|
+
* (graph mutation / terminalEvidence) is preserved inside the transform.
|
|
274
|
+
*
|
|
275
|
+
* `markBlocked` is intentionally NOT unified here (it sets status "skipped",
|
|
276
|
+
* not cancelled/failed, and only acts on "queued" tasks).
|
|
277
|
+
*/
|
|
278
|
+
export function cancelNonTerminalTasks(
|
|
279
|
+
tasks: TeamTaskState[],
|
|
280
|
+
status: "cancelled" | "failed",
|
|
281
|
+
reason: string,
|
|
282
|
+
filter?: (task: TeamTaskState) => boolean,
|
|
283
|
+
transform?: (task: TeamTaskState, terminalised: TeamTaskState) => TeamTaskState,
|
|
284
|
+
): TeamTaskState[] {
|
|
285
|
+
const predicate = filter ?? ((task: TeamTaskState) => isNonTerminalTaskStatus(task.status));
|
|
286
|
+
return tasks.map((task) => {
|
|
287
|
+
if (!predicate(task)) return task;
|
|
288
|
+
const terminalised: TeamTaskState = { ...task, status, finishedAt: new Date().toISOString(), error: reason };
|
|
289
|
+
return transform ? transform(task, terminalised) : terminalised;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 2.8: adaptive-plan parsing/repair/injection moved to src/runtime/goal-workflow/adaptive-plan.ts.
|
|
294
|
+
// Re-export the test-only helpers so existing test imports still resolve.
|
|
295
|
+
|
|
296
|
+
function retryPolicyFromConfig(config: CrewReliabilityConfig | undefined): RetryPolicy {
|
|
297
|
+
return { ...DEFAULT_RETRY_POLICY, ...(config?.retryPolicy ?? {}) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* #1 (assessment): decide whether the per-task retry path (executeWithRetry) is used.
|
|
302
|
+
* Defaults to TRUE (opt-out) so transient worker hangs (ChildTimeout) are retried
|
|
303
|
+
* automatically. Previously opt-in, which left the entire retry+recovery stack dormant.
|
|
304
|
+
* Exported for unit testing.
|
|
305
|
+
*/
|
|
306
|
+
export function shouldUseRetry(reliability: CrewReliabilityConfig | undefined): boolean {
|
|
307
|
+
return reliability?.autoRetry !== false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): TeamTaskState | undefined {
|
|
311
|
+
return result.tasks.find((item) => item.id === taskId && item.status === "failed");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Check whether any task uses explicit `dependsOn` that would benefit from DAG-based
|
|
316
|
+
* execution planning. If so, build an execution plan and use `getDagReadyTasks`
|
|
317
|
+
* to augment the ready-set selection.
|
|
318
|
+
*/
|
|
319
|
+
function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): string[] | null {
|
|
320
|
+
const hasExplicitDeps = tasks.some((t) => t.dependsOn.length > 0);
|
|
321
|
+
if (!hasExplicitDeps) return null;
|
|
322
|
+
// FIX (goal-wrap runtime test): task.dependsOn stores STEP IDs (e.g. "execute"), not
|
|
323
|
+
// task IDs (e.g. "02_execute"). The DAG scheduler compares deps against completedIds
|
|
324
|
+
// (which are task IDs), so step-ID deps would never match → dependent tasks stuck blocked
|
|
325
|
+
// forever. Map step IDs -> task IDs first (mirror dependencySatisfied in
|
|
326
|
+
// task-graph-scheduler.ts which handles this via stepToTaskId). buildDagExecutionPlan +
|
|
327
|
+
// getDagReadyTasks then work on consistent task IDs.
|
|
328
|
+
const stepToTaskId = new Map<string, string>();
|
|
329
|
+
for (const t of tasks) {
|
|
330
|
+
if (t.stepId) stepToTaskId.set(t.stepId, t.id);
|
|
331
|
+
}
|
|
332
|
+
const nodes: TaskNode[] = tasks.map((t) => ({
|
|
333
|
+
id: t.id,
|
|
334
|
+
dependsOn: t.dependsOn.map((dep) => stepToTaskId.get(dep) ?? dep),
|
|
335
|
+
phase: t.adaptive?.phase ?? t.stepId,
|
|
336
|
+
}));
|
|
337
|
+
const plan = buildDagExecutionPlan(nodes);
|
|
338
|
+
if (plan.hasCycle) return null; // fall back to existing scheduler
|
|
339
|
+
return getDagReadyTasks(plan, completedIds);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* CORE-4 extraction 3: select the dispatch batch for the current loop
|
|
344
|
+
* iteration.
|
|
345
|
+
*
|
|
346
|
+
* Computes the task-graph snapshot, DAG-ready tasks, workflow phase
|
|
347
|
+
* preconditions, batch concurrency, write-path-overlap serialization,
|
|
348
|
+
* coalesced-group logging, and streaming-dispatch slot allocation to
|
|
349
|
+
* determine which tasks are ready to dispatch this cycle.
|
|
350
|
+
*
|
|
351
|
+
* Returns:
|
|
352
|
+
* - `{ kind: "return", result }` when the run must block or abort
|
|
353
|
+
* (plan-approval pending with mutating tasks, or no ready task at all).
|
|
354
|
+
* - `{ kind: "dispatch", batch, ... }` when a batch is selected (may be
|
|
355
|
+
* empty when tasks are still in-flight — the caller proceeds to the
|
|
356
|
+
* wait phase with an empty dispatch set).
|
|
357
|
+
*
|
|
358
|
+
* The caller syncs `ctx.wfMachine` back after the call because this
|
|
359
|
+
* function may advance the workflow phase state machine.
|
|
360
|
+
*
|
|
361
|
+
* @param ctx The scheduler context; `ctx.wfMachine`, `ctx.tasks`, and
|
|
362
|
+
* `ctx.manifest` may be mutated in-place.
|
|
363
|
+
*/
|
|
364
|
+
/**
|
|
365
|
+
* Pre-batch sweeps, shared by every scheduler tick (selectDispatchBatch) —
|
|
366
|
+
* exported separately so the WIRING (not just the sweep internals) is testable
|
|
367
|
+
* without driving a full team run (code review R7c).
|
|
368
|
+
*
|
|
369
|
+
* 1. WP-2/R2 (ADR-0 item 10): parked-ask deadline sweep — every tick, before
|
|
370
|
+
* batch selection (a requeued park must be dispatchable this tick).
|
|
371
|
+
* 2. T2/R4 (ADR-4 §4): re-plan reconciliation — queued tasks of items dropped
|
|
372
|
+
* by the current revision are cancelled, in-flight ones get a wrap-up
|
|
373
|
+
* advisory (soft cancel). Cheap-exits inside make this a no-op for runs
|
|
374
|
+
* without plan-linked tasks.
|
|
375
|
+
* Mutates ctx.manifest/ctx.tasks from the sweeps' disk-reloaded results.
|
|
376
|
+
*/
|
|
377
|
+
export async function runSchedulerSweeps(ctx: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): Promise<void> {
|
|
378
|
+
const sweep = await sweepExpiredWaitingTasks(ctx.manifest.cwd, ctx.manifest.runId);
|
|
379
|
+
if (sweep) {
|
|
380
|
+
ctx.manifest = sweep.manifest;
|
|
381
|
+
ctx.tasks = sweep.tasks;
|
|
382
|
+
}
|
|
383
|
+
const droppedSweep = sweepDroppedPlanItems(ctx.manifest, ctx.tasks);
|
|
384
|
+
if (droppedSweep) {
|
|
385
|
+
ctx.manifest = droppedSweep.manifest;
|
|
386
|
+
ctx.tasks = droppedSweep.tasks;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function selectDispatchBatch(ctx: SchedulerContext): Promise<SchedulerDecision> {
|
|
391
|
+
// WP-2/R2 (ADR-0 item 10): this tick owns parked-ask deadlines — every
|
|
392
|
+
// scheduler iteration sweeps tasks with an expired `task.waiting.deadline`
|
|
393
|
+
// BEFORE batch selection (a requeued park must be dispatchable this tick).
|
|
394
|
+
await runSchedulerSweeps(ctx);
|
|
395
|
+
const snapshot = taskGraphSnapshot(ctx.tasks, ctx.queueIndex);
|
|
396
|
+
|
|
397
|
+
// DAG-based execution plan: when tasks have explicit dependsOn, use the
|
|
398
|
+
// topological wave planner to determine ready tasks. Fall back to the
|
|
399
|
+
// existing task-graph-scheduler when no explicit deps exist (backward compat).
|
|
400
|
+
const completedIds = new Set(ctx.tasks.filter((t) => t.status === "completed" || t.status === "needs_attention").map((t) => t.id));
|
|
401
|
+
const dagReady = dagReadyTaskIds(ctx.tasks, completedIds);
|
|
402
|
+
const readyBeforeFilter = dagReady ?? snapshot.ready;
|
|
403
|
+
|
|
404
|
+
// Workflow phase precondition check (non-blocking: log warnings only).
|
|
405
|
+
if (ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length) {
|
|
406
|
+
const completedArtifacts = ctx.manifest.artifacts.filter((a) => a.kind === "result" || a.kind === "summary").map((a) => a.path);
|
|
407
|
+
const previousPhaseStatus =
|
|
408
|
+
ctx.wfMachine.currentPhaseIndex > 0
|
|
409
|
+
? (ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex - 1]?.status ?? "pending")
|
|
410
|
+
: "completed";
|
|
411
|
+
const wfContext: PhaseGuardContext = {
|
|
412
|
+
completedArtifacts,
|
|
413
|
+
previousPhaseStatus,
|
|
414
|
+
taskResults: ctx.tasks
|
|
415
|
+
.filter((t) => t.status === "completed" || t.status === "needs_attention")
|
|
416
|
+
.map((t) => ({
|
|
417
|
+
taskId: t.id,
|
|
418
|
+
status: t.status,
|
|
419
|
+
outputPath: t.resultArtifact?.path,
|
|
420
|
+
})),
|
|
421
|
+
};
|
|
422
|
+
const preconditions = validatePhasePreconditions(ctx.wfMachine, wfContext);
|
|
423
|
+
if (!preconditions.ready) {
|
|
424
|
+
await appendEventAsync(ctx.manifest.eventsPath, {
|
|
425
|
+
type: "workflow.preconditions",
|
|
426
|
+
runId: ctx.manifest.runId,
|
|
427
|
+
message: `Workflow phase '${ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex]?.name}' is missing inputs: ${preconditions.blocking.join(", ")}`,
|
|
428
|
+
data: {
|
|
429
|
+
phaseIndex: ctx.wfMachine.currentPhaseIndex,
|
|
430
|
+
phaseName: ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex]?.name,
|
|
431
|
+
blocking: preconditions.blocking,
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
} else {
|
|
435
|
+
// Advance the machine past completed phases.
|
|
436
|
+
while (
|
|
437
|
+
ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length &&
|
|
438
|
+
ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex]?.status === "completed"
|
|
439
|
+
) {
|
|
440
|
+
ctx.wfMachine = {
|
|
441
|
+
...ctx.wfMachine,
|
|
442
|
+
currentPhaseIndex: ctx.wfMachine.currentPhaseIndex + 1,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// W5-4: by-id map once (was O(ready × tasks) via find-per-element).
|
|
449
|
+
const taskByIdReady = new Map(ctx.tasks.map((t) => [t.id, t] as const));
|
|
450
|
+
const readyRoles = readyBeforeFilter.map((taskId) => taskByIdReady.get(taskId)?.role).filter((role): role is string => Boolean(role));
|
|
451
|
+
const concurrency = resolveBatchConcurrency({
|
|
452
|
+
workflowName: ctx.workflow.name,
|
|
453
|
+
workflowMaxConcurrency: ctx.workflow.maxConcurrency,
|
|
454
|
+
teamMaxConcurrency: ctx.input.team.maxConcurrency,
|
|
455
|
+
limitMaxConcurrentWorkers: ctx.input.limits?.maxConcurrentWorkers,
|
|
456
|
+
allowUnboundedConcurrency: ctx.input.limits?.allowUnboundedConcurrency,
|
|
457
|
+
readyCount: readyBeforeFilter.length,
|
|
458
|
+
workspaceMode: ctx.manifest.workspaceMode,
|
|
459
|
+
readyRoles,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Round 25 (M5): serialize on write-path overlap when opted in.
|
|
463
|
+
// Opt-in via limits.serializeOnPathOverlap; default off (= no behavior change).
|
|
464
|
+
// filterReadyByWriteOverlap returns the same array when enabled=false, so
|
|
465
|
+
// production runs pay nothing for the unused code path. When the flag is on,
|
|
466
|
+
// `serializedReady` MAY be a strict subset of `readyBeforeFilter` (conflicting tasks
|
|
467
|
+
// deferred to next cycle).
|
|
468
|
+
const serializedReady = filterReadyByWriteOverlap(
|
|
469
|
+
readyBeforeFilter,
|
|
470
|
+
ctx.tasks,
|
|
471
|
+
ctx.workflow,
|
|
472
|
+
concurrency.maxConcurrent,
|
|
473
|
+
ctx.input.limits?.serializeOnPathOverlap === true,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Round 25 (M6): coalesce micro-tasks when opted in.
|
|
477
|
+
// Default off; when on, groups same-(role,cwd) tasks into coalesced groups
|
|
478
|
+
// (with write-path safety). In v0.9.17 first ship, we ONLY log the
|
|
479
|
+
// coalesced group count to the event stream (informational). Actual
|
|
480
|
+
// dispatching of one-multi-task worker instead of N workers is deferred
|
|
481
|
+
// to a follow-up — it's a non-trivial prompt-construction change that
|
|
482
|
+
// deserves its own PR. For now, every coalesced group => one info event.
|
|
483
|
+
const coalesceEnabled = ctx.workflow.coalesceMicroTasks === true;
|
|
484
|
+
if (coalesceEnabled) {
|
|
485
|
+
const coalescedGroups = planCoalescedGroups(serializedReady, ctx.tasks, ctx.workflow, true);
|
|
486
|
+
for (const group of coalescedGroups) {
|
|
487
|
+
if (group.tasks.length < 2) continue; // singletons are not interesting
|
|
488
|
+
await appendEventAsync(ctx.manifest.eventsPath, {
|
|
489
|
+
type: "task.coalesced",
|
|
490
|
+
runId: ctx.manifest.runId,
|
|
491
|
+
message: `Coalesced ${group.tasks.length} micro-tasks (role=${group.role}, cwd=${group.cwd})`,
|
|
492
|
+
data: {
|
|
493
|
+
groupId: group.id,
|
|
494
|
+
role: group.role,
|
|
495
|
+
cwd: group.cwd,
|
|
496
|
+
taskIds: group.tasks.map((task) => task.id),
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (concurrency.reason.includes(";unbounded:")) {
|
|
502
|
+
await appendEventAsync(ctx.manifest.eventsPath, {
|
|
503
|
+
type: "limits.unbounded",
|
|
504
|
+
runId: ctx.manifest.runId,
|
|
505
|
+
message: "Unbounded worker concurrency was explicitly enabled for this run.",
|
|
506
|
+
data: {
|
|
507
|
+
concurrencyReason: concurrency.reason,
|
|
508
|
+
maxConcurrent: concurrency.maxConcurrent,
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
// ── OPT-01 streaming dispatch: exclude tasks already in-flight, limit
|
|
513
|
+
// new dispatches to available concurrency slots. ──
|
|
514
|
+
const inFlightTaskIds = new Set<string>();
|
|
515
|
+
for (const pendingUnit of ctx.pendingUnits.values()) {
|
|
516
|
+
for (const taskId of pendingUnit.taskIds) inFlightTaskIds.add(taskId);
|
|
517
|
+
}
|
|
518
|
+
const slotsAvailable = Math.max(0, concurrency.maxConcurrent - ctx.pendingUnits.size);
|
|
519
|
+
// Review R3: the dispatch gate reads plan-record-first (ADR-4 §8) — in the
|
|
520
|
+
// crash window between the record write and the manifest save, the record's
|
|
521
|
+
// decision wins (resolve in favor of user intent).
|
|
522
|
+
const approvalPending = isPlanApprovalPendingEffective(ctx.manifest);
|
|
523
|
+
const dispatchableReady = serializedReady.filter((id) => !inFlightTaskIds.has(id));
|
|
524
|
+
const readyIds = approvalPending ? dispatchableReady : dispatchableReady.slice(0, slotsAvailable);
|
|
525
|
+
const taskByIdDispatch = new Map(ctx.tasks.map((t) => [t.id, t] as const));
|
|
526
|
+
const candidateBatch = readyIds.map((id) => taskByIdDispatch.get(id)).filter((task): task is TeamTaskState => Boolean(task));
|
|
527
|
+
const readyBatch = approvalPending ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, slotsAvailable) : candidateBatch;
|
|
528
|
+
if (readyBatch.length === 0) {
|
|
529
|
+
if (ctx.pendingUnits.size > 0) {
|
|
530
|
+
// Tasks are in-flight — skip dispatch and proceed to wait phase.
|
|
531
|
+
// (No return; code falls through to the dispatch section which is
|
|
532
|
+
// a no-op with an empty readyBatch, then reaches the wait phase.)
|
|
533
|
+
} else if (approvalPending && candidateBatch.some(isMutatingTask)) {
|
|
534
|
+
await saveRunTasksAsync(ctx.manifest, ctx.tasks);
|
|
535
|
+
saveCrewAgents(ctx.manifest, recordsForMaterializedTasks(ctx.manifest, ctx.tasks, ctx.runtimeKind));
|
|
536
|
+
ctx.manifest = updateRunStatus(ctx.manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
|
|
537
|
+
return { kind: "return", result: { manifest: ctx.manifest, tasks: ctx.tasks } };
|
|
538
|
+
} else {
|
|
539
|
+
ctx.tasks = markBlocked(ctx.tasks, "No ready queued task; dependency graph may be invalid.");
|
|
540
|
+
await saveRunTasksAsync(ctx.manifest, ctx.tasks);
|
|
541
|
+
saveCrewAgents(ctx.manifest, recordsForMaterializedTasks(ctx.manifest, ctx.tasks, ctx.runtimeKind));
|
|
542
|
+
ctx.manifest = updateRunStatus(ctx.manifest, "blocked", "No ready queued task.");
|
|
543
|
+
return { kind: "return", result: { manifest: ctx.manifest, tasks: ctx.tasks } };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return { kind: "dispatch", batch: readyBatch, concurrency, snapshot, approvalPending, coalesceEnabled };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export type DispatchBatchDecision = Extract<SchedulerDecision, { kind: "dispatch" }>;
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* CORE-4 extraction 4: execute the dispatch batch selected by
|
|
554
|
+
* selectDispatchBatch.
|
|
555
|
+
*
|
|
556
|
+
* Runs before_task_start hooks (skipping blocked tasks), builds coalesced
|
|
557
|
+
* dispatch units, pre-warms the stable-prefix cache for unique cwds, and
|
|
558
|
+
* dispatches each unit into ctx.pendingUnits as a fire-and-forget promise
|
|
559
|
+
* (wrapped in executeWithRetry on the singleton path). The function is a
|
|
560
|
+
* verbatim lift of the inline dispatch block; it does not return a
|
|
561
|
+
* SchedulerDecision (void — it only populates ctx.pendingUnits).
|
|
562
|
+
*
|
|
563
|
+
* Reads ctx.manifest/tasks/workflow/input + runController.signal. Mutates
|
|
564
|
+
* ctx.pendingUnits (add), ctx.tasks (hook skips), ctx.manifest (hook
|
|
565
|
+
* status). The mutable manifest/tasks are accessed via ctx.* (not captured
|
|
566
|
+
* locals) so that async retry callbacks observe the caller's re-synced
|
|
567
|
+
* values, matching the original closure semantics.
|
|
568
|
+
*
|
|
569
|
+
* @param ctx The scheduler context.
|
|
570
|
+
* @param decision The dispatch decision from selectDispatchBatch.
|
|
571
|
+
*/
|
|
572
|
+
export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBatchDecision): Promise<void> {
|
|
573
|
+
const { batch: readyBatch, concurrency, snapshot, approvalPending, coalesceEnabled } = decision;
|
|
574
|
+
// Immutable context fields captured once; manifest/tasks are accessed via
|
|
575
|
+
// ctx.* because they may be re-synced by the caller between dispatch and
|
|
576
|
+
// promise resolution (retry callbacks fire asynchronously).
|
|
577
|
+
const { workflow, input, runtimeKind, runController } = ctx;
|
|
578
|
+
|
|
579
|
+
// 2.2 caller migration: batch progress is high-frequency informational (M7 wire).
|
|
580
|
+
void appendEventBuffered(ctx.manifest.eventsPath, {
|
|
581
|
+
type: "task.progress",
|
|
582
|
+
runId: ctx.manifest.runId,
|
|
583
|
+
message: `Starting ready batch with ${readyBatch.length} task(s).`,
|
|
584
|
+
data: {
|
|
585
|
+
taskIds: readyBatch.map((task) => task.id),
|
|
586
|
+
readyCount: snapshot.ready.length,
|
|
587
|
+
blockedCount: snapshot.blocked.length,
|
|
588
|
+
runningCount: snapshot.running.length,
|
|
589
|
+
doneCount: snapshot.done.length,
|
|
590
|
+
selectedCount: readyBatch.length,
|
|
591
|
+
maxConcurrent: concurrency.maxConcurrent,
|
|
592
|
+
defaultConcurrency: concurrency.defaultConcurrency,
|
|
593
|
+
concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason,
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
// Execute before_task_start hooks for the batch — P1-10: run hooks in
|
|
597
|
+
// parallel (each may be a subprocess), then apply skip mutations in order.
|
|
598
|
+
const beforeTaskStartReports = await Promise.all(
|
|
599
|
+
readyBatch.map((task) =>
|
|
600
|
+
executeHook("before_task_start", {
|
|
601
|
+
runId: ctx.manifest.runId,
|
|
602
|
+
taskId: task.id,
|
|
603
|
+
cwd: ctx.manifest.cwd,
|
|
604
|
+
}).then((taskReport) => ({ task, taskReport })),
|
|
605
|
+
),
|
|
606
|
+
);
|
|
607
|
+
for (const { task, taskReport } of beforeTaskStartReports) {
|
|
608
|
+
appendHookEvent(ctx.manifest, taskReport);
|
|
609
|
+
if (taskReport.outcome === "block") {
|
|
610
|
+
ctx.tasks = ctx.tasks.map((t) =>
|
|
611
|
+
t.id === task.id
|
|
612
|
+
? {
|
|
613
|
+
...t,
|
|
614
|
+
status: "skipped" as const,
|
|
615
|
+
error: taskReport.reason ?? "before_task_start hook blocked execution.",
|
|
616
|
+
}
|
|
617
|
+
: t,
|
|
618
|
+
);
|
|
619
|
+
ctx.manifest = updateRunStatus(ctx.manifest, ctx.manifest.status, `Task '${task.id}' blocked by hook.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// W5-4: by-id map (was O(readyBatch × tasks) via find-per-element).
|
|
623
|
+
const ctxTaskById = new Map(ctx.tasks.map((t) => [t.id, t] as const));
|
|
624
|
+
const batchTasks = readyBatch.filter((task) => {
|
|
625
|
+
const t = ctxTaskById.get(task.id);
|
|
626
|
+
return t !== undefined && t.status !== "skipped";
|
|
627
|
+
});
|
|
628
|
+
if (batchTasks.length > 1) {
|
|
629
|
+
await appendEventAsync(ctx.manifest.eventsPath, {
|
|
630
|
+
type: "task.parallel_start",
|
|
631
|
+
runId: ctx.manifest.runId,
|
|
632
|
+
message: `Launching ${batchTasks.length} tasks in PARALLEL (concurrency=${concurrency.selectedCount}): ${batchTasks.map((t) => `${t.role}(${t.id})`).join(", ")}`,
|
|
633
|
+
data: {
|
|
634
|
+
taskIds: batchTasks.map((t) => t.id),
|
|
635
|
+
roles: batchTasks.map((t) => t.role),
|
|
636
|
+
concurrency: concurrency.selectedCount,
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// M6 real dispatch: when coalesceMicroTasks is enabled, batch the
|
|
642
|
+
// ready tasks into dispatch units. Multi-task groups are dispatched
|
|
643
|
+
// as one worker (single cold-start) instead of N. Singletons fall
|
|
644
|
+
// through to per-task dispatch.
|
|
645
|
+
const coalescedGroups = planCoalescedGroups(
|
|
646
|
+
batchTasks.map((t) => t.id),
|
|
647
|
+
ctx.tasks,
|
|
648
|
+
workflow,
|
|
649
|
+
coalesceEnabled,
|
|
650
|
+
);
|
|
651
|
+
const dispatchUnits = buildDispatchUnits(
|
|
652
|
+
batchTasks.map((t) => t.id),
|
|
653
|
+
coalescedGroups,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
// NEW-M1: Pre-warm stable prefix cache for one representative task
|
|
657
|
+
// per unique cwd. Parallel siblings with the same cwd/step reuse
|
|
658
|
+
// the cached workspace tree, file retrieval, and knowledge fragment
|
|
659
|
+
// instead of recomputing them independently (~200-800ms per batch).
|
|
660
|
+
if (batchTasks.length > 1) {
|
|
661
|
+
const seenCwds = new Set<string>();
|
|
662
|
+
await Promise.all(
|
|
663
|
+
batchTasks
|
|
664
|
+
.filter((task) => {
|
|
665
|
+
if (seenCwds.has(task.cwd)) return false;
|
|
666
|
+
seenCwds.add(task.cwd);
|
|
667
|
+
return true;
|
|
668
|
+
})
|
|
669
|
+
.map((task) => {
|
|
670
|
+
const step = findStep(workflow, task);
|
|
671
|
+
return computeStablePrefixComponents(ctx.manifest, step, task);
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── OPT-01 streaming dispatch: dispatch each unit into ctx.pendingUnits
|
|
677
|
+
// instead of awaiting the entire batch via mapConcurrent. Each unit's
|
|
678
|
+
// promise is stored so we can Promise.race on the next iteration. ──
|
|
679
|
+
const dispatchUnit = async (unit: DispatchUnit): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> => {
|
|
680
|
+
// M6 real dispatch path: single worker for N tasks.
|
|
681
|
+
if (unit.kind === "group") {
|
|
682
|
+
const groupTasks = unit.group.tasks;
|
|
683
|
+
const firstTask = groupTasks[0]!;
|
|
684
|
+
const step = findStep(workflow, firstTask);
|
|
685
|
+
const agent = findAgent(input.agents, firstTask);
|
|
686
|
+
const teamRole = input.team.roles.find((role) => role.name === firstTask.role);
|
|
687
|
+
const perTaskRuntime = resolveTaskRuntimeKind(runtimeKind, firstTask.role, input.runtimeConfig?.isolationPolicy);
|
|
688
|
+
return runCoalescedTaskGroup({
|
|
689
|
+
manifest: ctx.manifest,
|
|
690
|
+
tasks: ctx.tasks,
|
|
691
|
+
groupTasks,
|
|
692
|
+
step,
|
|
693
|
+
agent,
|
|
694
|
+
signal: runController.signal,
|
|
695
|
+
executeWorkers: input.executeWorkers,
|
|
696
|
+
runtimeKind,
|
|
697
|
+
workspaceId: input.workspaceId,
|
|
698
|
+
onJsonEvent: input.onJsonEvent,
|
|
699
|
+
runtimeConfig: input.runtimeConfig,
|
|
700
|
+
reliability: input.reliability,
|
|
701
|
+
teamRole,
|
|
702
|
+
perTaskRuntime,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
// Singleton path: original per-task dispatch.
|
|
706
|
+
const task = batchTasks.find((t) => t.id === unit.taskId)!;
|
|
707
|
+
const step = findStep(workflow, task);
|
|
708
|
+
const agent = findAgent(input.agents, task);
|
|
709
|
+
const teamRole = input.team.roles.find((role) => role.name === task.role);
|
|
710
|
+
const perTaskRuntime = resolveTaskRuntimeKind(runtimeKind, task.role, input.runtimeConfig?.isolationPolicy);
|
|
711
|
+
// CORE-3: compute retry policy + spawn budget ONCE per dispatch unit.
|
|
712
|
+
// The spawnBudget object is shared (by reference) across every
|
|
713
|
+
// runTeamTask call within executeWithRetry via baseInput spread,
|
|
714
|
+
// so the counter accumulates across retry attempts × model fallbacks.
|
|
715
|
+
const policy = retryPolicyFromConfig(input.reliability);
|
|
716
|
+
const spawnBudget: SpawnBudget = { count: 0, max: policy.maxTotalSpawns ?? 0 };
|
|
717
|
+
const baseInput = {
|
|
718
|
+
manifest: ctx.manifest,
|
|
719
|
+
tasks: ctx.tasks,
|
|
720
|
+
task,
|
|
721
|
+
step,
|
|
722
|
+
agent,
|
|
723
|
+
signal: runController.signal,
|
|
724
|
+
executeWorkers: input.executeWorkers,
|
|
725
|
+
runtimeKind: runtimeKind,
|
|
726
|
+
taskRuntimeOverride: perTaskRuntime !== runtimeKind ? perTaskRuntime : undefined,
|
|
727
|
+
runtimeConfig: input.runtimeConfig,
|
|
728
|
+
parentContext: input.parentContext,
|
|
729
|
+
parentModel: input.parentModel,
|
|
730
|
+
modelRegistry: input.modelRegistry,
|
|
731
|
+
modelOverride: input.modelOverride,
|
|
732
|
+
teamRoleModel: teamRole?.model,
|
|
733
|
+
teamRoleThinking: teamRole?.thinking,
|
|
734
|
+
teamRoleFallbackModels: teamRole?.fallbackModels,
|
|
735
|
+
teamRoleSkills: teamRole?.skills,
|
|
736
|
+
skillOverride: input.skillOverride,
|
|
737
|
+
limits: input.limits,
|
|
738
|
+
onJsonEvent: input.onJsonEvent,
|
|
739
|
+
workspaceId: input.workspaceId,
|
|
740
|
+
spawnBudget,
|
|
741
|
+
// R10-1 residual: same per-run cache instance as the closeout — one
|
|
742
|
+
// object reference spread into EVERY runTeamTask call (initial + every
|
|
743
|
+
// retry attempt via `...baseInput`), so dep-context reads and closeout
|
|
744
|
+
// aggregation share memoized artifacts.
|
|
745
|
+
resultReadCache: ctx.resultReadCache,
|
|
746
|
+
};
|
|
747
|
+
// #1 (assessment): autoRetry now defaults ON (opt-out via reliability.autoRetry=false).
|
|
748
|
+
// The dominant v0.9.13 failure was ChildTimeout ("worker became unresponsive") with
|
|
749
|
+
// ZERO retries because this gate was opt-in. isRetryable() defaults to true when
|
|
750
|
+
// retryableErrors is empty, so transient hangs now retry up to maxAttempts (3) with
|
|
751
|
+
// exponential backoff. Set reliability.autoRetry=false to restore old single-shot behavior.
|
|
752
|
+
if (!shouldUseRetry(input.reliability))
|
|
753
|
+
return withCorrelation(childCorrelation(ctx.manifest.runId, task.id), () => runTeamTask(baseInput));
|
|
754
|
+
let lastFailed: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
|
|
755
|
+
let lastAttemptId: string | undefined;
|
|
756
|
+
const attemptsSoFar: TaskAttemptState[] = [...(task.attempts ?? [])];
|
|
757
|
+
try {
|
|
758
|
+
return await executeWithRetry(
|
|
759
|
+
async (attempt, info) => {
|
|
760
|
+
const startedAt = new Date().toISOString();
|
|
761
|
+
const inFlightAttempts: TaskAttemptState[] = [...attemptsSoFar, { attemptId: info.attemptId, startedAt }];
|
|
762
|
+
input.metricRegistry?.counter("crew.task.retry_attempt_total", "Retry attempts by run and task").inc({
|
|
763
|
+
runId: ctx.manifest.runId,
|
|
764
|
+
taskId: task.id,
|
|
765
|
+
});
|
|
766
|
+
// NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
|
|
767
|
+
const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
768
|
+
const freshManifest = fresh?.manifest ?? ctx.manifest;
|
|
769
|
+
const freshTasks = fresh?.tasks ?? ctx.tasks;
|
|
770
|
+
const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
|
|
771
|
+
if (freshTask.status !== "queued" && freshTask.status !== "running")
|
|
772
|
+
return {
|
|
773
|
+
manifest: freshManifest,
|
|
774
|
+
tasks: freshTasks,
|
|
775
|
+
};
|
|
776
|
+
const taskWithAttempt: TeamTaskState = {
|
|
777
|
+
...freshTask,
|
|
778
|
+
attempts: inFlightAttempts,
|
|
779
|
+
};
|
|
780
|
+
const result = await withCorrelation(childCorrelation(freshManifest.runId, task.id), () =>
|
|
781
|
+
runTeamTask({
|
|
782
|
+
...baseInput,
|
|
783
|
+
manifest: freshManifest,
|
|
784
|
+
tasks: freshTasks,
|
|
785
|
+
task: taskWithAttempt,
|
|
786
|
+
}),
|
|
787
|
+
);
|
|
788
|
+
const failed = failedTaskFrom(result, task.id);
|
|
789
|
+
const endedAt = new Date().toISOString();
|
|
790
|
+
const finishedAttempt: TaskAttemptState = {
|
|
791
|
+
attemptId: info.attemptId,
|
|
792
|
+
startedAt,
|
|
793
|
+
endedAt,
|
|
794
|
+
...(failed?.error ? { error: failed.error } : {}),
|
|
795
|
+
};
|
|
796
|
+
attemptsSoFar.push(finishedAttempt);
|
|
797
|
+
const withAttempt = result.tasks.map((item) =>
|
|
798
|
+
item.id === task.id ? { ...item, attempts: [...attemptsSoFar] } : item,
|
|
799
|
+
);
|
|
800
|
+
const enriched = {
|
|
801
|
+
manifest: result.manifest,
|
|
802
|
+
tasks: withAttempt,
|
|
803
|
+
};
|
|
804
|
+
if (failed) {
|
|
805
|
+
lastFailed = enriched;
|
|
806
|
+
throw new CrewError(ErrorCode.TaskNotFound, failed.error ?? `Task ${task.id} failed.`).withContext(
|
|
807
|
+
`retry evaluation (run=${ctx.manifest.runId})`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe(
|
|
811
|
+
{
|
|
812
|
+
runId: ctx.manifest.runId,
|
|
813
|
+
team: input.team.name,
|
|
814
|
+
},
|
|
815
|
+
Math.max(0, attempt - 1),
|
|
816
|
+
);
|
|
817
|
+
return enriched;
|
|
818
|
+
},
|
|
819
|
+
policy,
|
|
820
|
+
{
|
|
821
|
+
signal: runController.signal,
|
|
822
|
+
attemptId: (attempt) => `${ctx.manifest.runId}:${task.id}:attempt-${attempt}`,
|
|
823
|
+
onAttemptFailed: (attempt, error, delayMs, info) => {
|
|
824
|
+
lastAttemptId = info.attemptId;
|
|
825
|
+
appendEventAsync(ctx.manifest.eventsPath, {
|
|
826
|
+
type: "crew.task.retry_attempt",
|
|
827
|
+
runId: ctx.manifest.runId,
|
|
828
|
+
taskId: task.id,
|
|
829
|
+
message: error.message,
|
|
830
|
+
data: {
|
|
831
|
+
attempt,
|
|
832
|
+
attemptId: info.attemptId,
|
|
833
|
+
delayMs,
|
|
834
|
+
},
|
|
835
|
+
metadata: { attemptId: info.attemptId },
|
|
836
|
+
}).catch((error) => logInternalError("team-runner.retry-attempt", error, `taskId=${task.id}`));
|
|
837
|
+
input.metricRegistry?.histogram("crew.task.retry_delay_ms", "Retry backoff delay, milliseconds").observe(
|
|
838
|
+
{
|
|
839
|
+
runId: ctx.manifest.runId,
|
|
840
|
+
taskId: task.id,
|
|
841
|
+
},
|
|
842
|
+
delayMs,
|
|
843
|
+
);
|
|
844
|
+
},
|
|
845
|
+
onRetryGivenUp: (attempts, error, info) => {
|
|
846
|
+
lastAttemptId = info.attemptId;
|
|
847
|
+
appendDeadletter(ctx.manifest, {
|
|
848
|
+
runId: ctx.manifest.runId,
|
|
849
|
+
taskId: task.id,
|
|
850
|
+
reason: "max-retries",
|
|
851
|
+
attempts,
|
|
852
|
+
attemptId: info.attemptId,
|
|
853
|
+
lastError: error.message,
|
|
854
|
+
timestamp: new Date().toISOString(),
|
|
855
|
+
});
|
|
856
|
+
input.metricRegistry
|
|
857
|
+
?.counter("crew.task.deadletter_total", "Deadletter triggers by reason")
|
|
858
|
+
.inc({ reason: "max-retries" });
|
|
859
|
+
input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe(
|
|
860
|
+
{
|
|
861
|
+
runId: ctx.manifest.runId,
|
|
862
|
+
team: input.team.name,
|
|
863
|
+
},
|
|
864
|
+
Math.max(0, attempts - 1),
|
|
865
|
+
);
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
);
|
|
869
|
+
} catch (retryError) {
|
|
870
|
+
if (retryError instanceof CrewCancellationError || input.signal?.aborted) {
|
|
871
|
+
const reason = retryError instanceof CrewCancellationError ? retryError.reason : cancellationReasonFromSignal(input.signal);
|
|
872
|
+
// NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
|
|
873
|
+
const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
874
|
+
const freshManifest = fresh?.manifest ?? ctx.manifest;
|
|
875
|
+
const freshTasks = fresh?.tasks ?? ctx.tasks;
|
|
876
|
+
const cancelledTasks = cancelNonTerminalTasks(
|
|
877
|
+
freshTasks,
|
|
878
|
+
"cancelled",
|
|
879
|
+
`${reason.message} (${reason.code})`,
|
|
880
|
+
(item) => item.id === task.id && (item.status === "queued" || item.status === "running"),
|
|
881
|
+
);
|
|
882
|
+
appendEventAsync(freshManifest.eventsPath, {
|
|
883
|
+
type: "task.cancelled",
|
|
884
|
+
runId: freshManifest.runId,
|
|
885
|
+
taskId: task.id,
|
|
886
|
+
message: reason.message,
|
|
887
|
+
data: { reason, phase: "retry" },
|
|
888
|
+
metadata: lastAttemptId ? { attemptId: lastAttemptId } : undefined,
|
|
889
|
+
}).catch((error) => logInternalError("team-runner.cancelled", error, `taskId=${task.id}`));
|
|
890
|
+
return {
|
|
891
|
+
manifest: updateRunStatus(freshManifest, "cancelled", reason.message),
|
|
892
|
+
tasks: cancelledTasks,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
if (lastFailed) return lastFailed;
|
|
896
|
+
// NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
|
|
897
|
+
const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
898
|
+
const freshManifest = fresh?.manifest ?? ctx.manifest;
|
|
899
|
+
const freshTasks = fresh?.tasks ?? ctx.tasks;
|
|
900
|
+
const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
|
|
901
|
+
if (freshTask.status !== "queued" && freshTask.status !== "running") return { manifest: freshManifest, tasks: freshTasks };
|
|
902
|
+
return withCorrelation(childCorrelation(freshManifest.runId, task.id), () =>
|
|
903
|
+
runTeamTask({
|
|
904
|
+
...baseInput,
|
|
905
|
+
manifest: freshManifest,
|
|
906
|
+
tasks: freshTasks,
|
|
907
|
+
task: freshTask,
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
// ── OPT-01 streaming dispatch: dispatch units into ctx.pendingUnits ──
|
|
913
|
+
for (const unit of dispatchUnits) {
|
|
914
|
+
const unitKey = unit.kind === "singleton" ? unit.taskId : unit.group.id;
|
|
915
|
+
const unitTaskIds = unit.kind === "singleton" ? [unit.taskId] : unit.group.tasks.map((t) => t.id);
|
|
916
|
+
// RT-12: create the wrapper promise ONCE at dispatch time so
|
|
917
|
+
// mergeUnitResult can Promise.race on pre-existing wrappers instead
|
|
918
|
+
// of allocating new async closures every loop iteration.
|
|
919
|
+
const rawPromise = dispatchUnit(unit);
|
|
920
|
+
const wrapped: Promise<SettledUnit> = (async () => {
|
|
921
|
+
try {
|
|
922
|
+
const result = await rawPromise;
|
|
923
|
+
return {
|
|
924
|
+
unitKey,
|
|
925
|
+
result: result as { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined,
|
|
926
|
+
error: undefined as Error | undefined,
|
|
927
|
+
};
|
|
928
|
+
} catch (error) {
|
|
929
|
+
return { unitKey, result: undefined, error: error instanceof Error ? error : new Error(String(error)) };
|
|
930
|
+
}
|
|
931
|
+
})();
|
|
932
|
+
ctx.pendingUnits.set(unitKey, {
|
|
933
|
+
taskIds: unitTaskIds,
|
|
934
|
+
promise: rawPromise,
|
|
935
|
+
wrapped,
|
|
936
|
+
});
|
|
937
|
+
// RT-NEW-2 race fix: record ever-dispatched task ids so terminaliseRunWithDrain
|
|
938
|
+
// cancels (not skips) tasks whose unit settled + left pendingUnits before
|
|
939
|
+
// the abort fired but whose task status isn't terminal yet.
|
|
940
|
+
for (const id of unitTaskIds) ctx.dispatchedTaskIds.add(id);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** @internal 1.9(b) test export — exercise selectDispatchBatch directly. */
|
|
945
|
+
export const __test__selectDispatchBatch = selectDispatchBatch;
|