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
|
@@ -21,8 +21,10 @@
|
|
|
21
21
|
* full contract and acceptance criteria.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
24
25
|
import * as fsp from "node:fs/promises";
|
|
25
26
|
import * as net from "node:net";
|
|
27
|
+
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
26
28
|
import {
|
|
27
29
|
appendMailboxMessageAsync,
|
|
28
30
|
type MailboxMessage,
|
|
@@ -31,15 +33,21 @@ import {
|
|
|
31
33
|
readMailbox,
|
|
32
34
|
registerMailboxAppendObserver,
|
|
33
35
|
} from "../../state/coordination/mailbox.ts";
|
|
34
|
-
import { readEventsCursor } from "../../state/event-log/event-log.ts";
|
|
35
|
-
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
36
|
+
import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
|
|
37
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
38
|
+
import type { TeamTaskState } from "../../state/types.ts";
|
|
36
39
|
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
37
40
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
38
41
|
import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
|
|
39
42
|
import { redactSecretString } from "../../utils/redaction.ts";
|
|
40
43
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
41
44
|
import { getBrokerSocketPath, prepareBrokerSocketDir, removeStaleBrokerSocket } from "../../utils/socket-path.ts";
|
|
45
|
+
import { type GrandchildSpawnInput, type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
|
|
46
|
+
import { resolveCrewMaxDepth } from "../model/pi-args.ts";
|
|
47
|
+
import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
48
|
+
import { evaluateDelegateAdmission } from "../spawn-policy.ts";
|
|
42
49
|
import { BrokerTokenRegistry } from "./crew-broker-tokens.ts";
|
|
50
|
+
import { WaitStatusCache } from "./wait-status-cache.ts";
|
|
43
51
|
|
|
44
52
|
/** Protocol version negotiated at `hello` time. Bump on breaking change. */
|
|
45
53
|
const BROKER_PROTOCOL = 1;
|
|
@@ -69,6 +77,38 @@ export interface CrewBrokerOptions {
|
|
|
69
77
|
cwd?: string;
|
|
70
78
|
/** Optional test seam: override the `net` module (allows fake-server tests). */
|
|
71
79
|
netModule?: typeof net;
|
|
80
|
+
/** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
|
|
81
|
+
* wrapping a loader spy). Production uses a plain cache — see
|
|
82
|
+
* wait-status-cache.ts (R10-3). */
|
|
83
|
+
waitStatusCache?: WaitStatusCache;
|
|
84
|
+
/** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
|
|
85
|
+
* gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
|
|
86
|
+
* explicitly true, wait.request/wait.resolve are rejected with a
|
|
87
|
+
* `policy-disabled` error AND a `policy.action` event is appended to the
|
|
88
|
+
* run's events.jsonl (never silent). The production wiring threads
|
|
89
|
+
* `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
|
|
90
|
+
waitMethodsEnabled?: boolean;
|
|
91
|
+
/** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
|
|
92
|
+
* FALSE — fail-closed until the WP-5 completion gate flips it. The
|
|
93
|
+
* production wiring threads `config.nesting.enabled` here; tests pass it
|
|
94
|
+
* explicitly. Rejections are NEVER silent (delegate.rejected event). */
|
|
95
|
+
nestingEnabled?: boolean;
|
|
96
|
+
/** Optional override for the nested-slot budget size (config nesting.maxSlots). */
|
|
97
|
+
nestingMaxSlots?: number;
|
|
98
|
+
nestingMaxDepth?: number;
|
|
99
|
+
nestingTrustedEscalation?: boolean;
|
|
100
|
+
/** Global worker semaphore size, used to size the nested-slot budget. */
|
|
101
|
+
globalWorkerSemaphore?: number;
|
|
102
|
+
/** Test seam / alternative spawner for delegate grandchildren. Production
|
|
103
|
+
* uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
|
|
104
|
+
grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
|
|
105
|
+
/** Resolved model catalog (canonical provider/id strings) for admission-time
|
|
106
|
+
* model validation (ADR-5 §7). When omitted, model validation is skipped
|
|
107
|
+
* (documented gap — the production wiring must always supply it). */
|
|
108
|
+
modelCatalog?: () => string[] | undefined;
|
|
109
|
+
/** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
|
|
110
|
+
* admission gate. Default false. */
|
|
111
|
+
serializeOnPathOverlap?: boolean;
|
|
72
112
|
}
|
|
73
113
|
|
|
74
114
|
/** Per-connection server-side state. */
|
|
@@ -83,6 +123,11 @@ interface ServerConnection {
|
|
|
83
123
|
taskId?: string;
|
|
84
124
|
/** Role bound by hello: orchestrator can steer/msg-send; workers default. */
|
|
85
125
|
role?: "orchestrator" | "worker";
|
|
126
|
+
/** How the hello token matched the registry (ADR-0 item 6). Derived,
|
|
127
|
+
* non-secret metadata recorded at hello time so `wait.*` can reject a
|
|
128
|
+
* legacy bare-runId fallback match WITHOUT keeping the raw token on the
|
|
129
|
+
* connection (tokens stay confined to the heap-only registry). */
|
|
130
|
+
authMatchKind?: "compound" | "runId-fallback";
|
|
86
131
|
/** Outbound queue of encoded frames awaiting drain. */
|
|
87
132
|
outbound: Buffer[];
|
|
88
133
|
/** Set when the queue has hit the cap and a frame was dropped. */
|
|
@@ -96,8 +141,23 @@ interface ServerConnection {
|
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
export class CrewBroker {
|
|
99
|
-
private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled">> &
|
|
100
|
-
Pick<
|
|
144
|
+
private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
|
|
145
|
+
Pick<
|
|
146
|
+
CrewBrokerOptions,
|
|
147
|
+
| "socketPath"
|
|
148
|
+
| "maxFrameBytes"
|
|
149
|
+
| "outboundQueueCap"
|
|
150
|
+
| "cwd"
|
|
151
|
+
| "netModule"
|
|
152
|
+
| "waitStatusCache"
|
|
153
|
+
| "nestingMaxSlots"
|
|
154
|
+
| "nestingMaxDepth"
|
|
155
|
+
| "nestingTrustedEscalation"
|
|
156
|
+
| "globalWorkerSemaphore"
|
|
157
|
+
| "grandchildSpawner"
|
|
158
|
+
| "modelCatalog"
|
|
159
|
+
| "serializeOnPathOverlap"
|
|
160
|
+
>;
|
|
101
161
|
private readonly tokens = new BrokerTokenRegistry();
|
|
102
162
|
private server: net.Server | null = null;
|
|
103
163
|
private resolvedSocketPath: string | null = null;
|
|
@@ -110,8 +170,14 @@ export class CrewBroker {
|
|
|
110
170
|
private readonly subscriptionUnsubs = new WeakMap<ServerConnection, Set<() => void>>();
|
|
111
171
|
/** Unsubscribe handle for the mailbox append observer (set on start, cleared on stop). */
|
|
112
172
|
private mailboxObserverUnsub: (() => void) | null = null;
|
|
173
|
+
/** T3/R5 (ADR-5 §2): nested-slot budget for delegate grandchildren — lazily
|
|
174
|
+
* sized from options (max(1, floor(globalWorkerSemaphore/2)) or override). */
|
|
175
|
+
private nestedSlots: NestedSlotBudget | undefined;
|
|
113
176
|
/** A single observable handshake counter (test/observability). */
|
|
114
177
|
private handshakeCount = 0;
|
|
178
|
+
/** R10-3: stat-gated manifest/tasks cache shared by all task.waitStatus
|
|
179
|
+
* waiters on this broker (keyed by runId — the broker serves one cwd). */
|
|
180
|
+
private readonly waitStatusCache: WaitStatusCache;
|
|
115
181
|
|
|
116
182
|
constructor(options: CrewBrokerOptions) {
|
|
117
183
|
if (!options || typeof options !== "object") {
|
|
@@ -128,7 +194,17 @@ export class CrewBroker {
|
|
|
128
194
|
outboundQueueCap: options.outboundQueueCap,
|
|
129
195
|
cwd: options.cwd,
|
|
130
196
|
netModule: options.netModule,
|
|
197
|
+
waitMethodsEnabled: options.waitMethodsEnabled === true,
|
|
198
|
+
nestingEnabled: options.nestingEnabled === true,
|
|
199
|
+
nestingMaxSlots: options.nestingMaxSlots,
|
|
200
|
+
nestingMaxDepth: options.nestingMaxDepth,
|
|
201
|
+
nestingTrustedEscalation: options.nestingTrustedEscalation === true,
|
|
202
|
+
globalWorkerSemaphore: options.globalWorkerSemaphore,
|
|
203
|
+
grandchildSpawner: options.grandchildSpawner,
|
|
204
|
+
modelCatalog: options.modelCatalog,
|
|
205
|
+
serializeOnPathOverlap: options.serializeOnPathOverlap === true,
|
|
131
206
|
};
|
|
207
|
+
this.waitStatusCache = options.waitStatusCache ?? new WaitStatusCache();
|
|
132
208
|
}
|
|
133
209
|
|
|
134
210
|
/** Read the resolved socket path. Available after start() resolves. */
|
|
@@ -362,6 +438,13 @@ export class CrewBroker {
|
|
|
362
438
|
// ------------------------------------------------------------------------
|
|
363
439
|
|
|
364
440
|
private async handleConnection(sock: net.Socket): Promise<void> {
|
|
441
|
+
// B1 (Round 14): a connection event queued after stop() must not be
|
|
442
|
+
// processed — the broker is shutting down and the token registry is
|
|
443
|
+
// already cleared. Destroy (not end) since the server is stopping.
|
|
444
|
+
if (this.stopped) {
|
|
445
|
+
sock.destroy();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
365
448
|
const conn: ServerConnection = {
|
|
366
449
|
socket: sock,
|
|
367
450
|
decoder: new NdjsonDecoder(),
|
|
@@ -369,6 +452,7 @@ export class CrewBroker {
|
|
|
369
452
|
runId: undefined,
|
|
370
453
|
taskId: undefined,
|
|
371
454
|
role: undefined,
|
|
455
|
+
authMatchKind: undefined,
|
|
372
456
|
outbound: [],
|
|
373
457
|
needsResync: false,
|
|
374
458
|
closed: false,
|
|
@@ -425,7 +509,12 @@ export class CrewBroker {
|
|
|
425
509
|
const set = this.connectionsByRun.get(conn.runId);
|
|
426
510
|
if (set) {
|
|
427
511
|
set.delete(conn);
|
|
428
|
-
if (set.size === 0)
|
|
512
|
+
if (set.size === 0) {
|
|
513
|
+
this.connectionsByRun.delete(conn.runId);
|
|
514
|
+
// R10-3: no more waiters for this run — drop its stat-gated
|
|
515
|
+
// manifest cache entry so the Map stays bounded across runs.
|
|
516
|
+
this.waitStatusCache.delete(conn.runId);
|
|
517
|
+
}
|
|
429
518
|
}
|
|
430
519
|
}
|
|
431
520
|
// Phase 2: tear down any per-connection event subscriptions.
|
|
@@ -544,6 +633,21 @@ export class CrewBroker {
|
|
|
544
633
|
case "escalate":
|
|
545
634
|
await this.handleEscalate(conn, id, params);
|
|
546
635
|
return;
|
|
636
|
+
// WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask items 3,6,7):
|
|
637
|
+
// waiting-producer park/resolve. Task-scoped tokens only;
|
|
638
|
+
// capability-gated via options.waitMethodsEnabled (fail-closed).
|
|
639
|
+
case "wait.request":
|
|
640
|
+
await this.handleWaitRequest(conn, id, params);
|
|
641
|
+
return;
|
|
642
|
+
case "wait.resolve":
|
|
643
|
+
await this.handleWaitResolve(conn, id, params);
|
|
644
|
+
return;
|
|
645
|
+
// T3/R5 (ADR-5 §1): governed-nesting delegation. Task-scoped tokens
|
|
646
|
+
// only; capability-gated via options.nestingEnabled (fail-closed);
|
|
647
|
+
// admission runs the full spawn-policy gate matrix.
|
|
648
|
+
case "delegate.request":
|
|
649
|
+
await this.handleDelegateRequest(conn, id, params);
|
|
650
|
+
return;
|
|
547
651
|
default:
|
|
548
652
|
// Unhandled method → typed not-implemented.
|
|
549
653
|
this.sendError(conn, id, "not-implemented", `method '${method}' is not implemented`);
|
|
@@ -571,8 +675,11 @@ export class CrewBroker {
|
|
|
571
675
|
// vs worker), never from a self-declared hello field (F-06: otherwise a
|
|
572
676
|
// worker could forge role:'orchestrator' and call steer.push/msg.send).
|
|
573
677
|
// Constant-time compare; never include the token in the error path.
|
|
574
|
-
|
|
575
|
-
|
|
678
|
+
// WP-2/R2 (ADR-0 item 6): the match KIND is recorded on the connection
|
|
679
|
+
// (compound vs legacy bare-runId fallback) so wait.* can enforce the
|
|
680
|
+
// task-scoped-token rule without retaining the secret candidate.
|
|
681
|
+
const resolved = this.tokens.tokenRoleWithMatchKind(runId, taskId, token);
|
|
682
|
+
if (resolved === null) {
|
|
576
683
|
this.sendErrorAndClose(conn, id, "auth", "hello rejected");
|
|
577
684
|
return;
|
|
578
685
|
}
|
|
@@ -591,7 +698,8 @@ export class CrewBroker {
|
|
|
591
698
|
conn.authed = true;
|
|
592
699
|
conn.runId = runId;
|
|
593
700
|
conn.taskId = taskId;
|
|
594
|
-
conn.role = role;
|
|
701
|
+
conn.role = resolved.role;
|
|
702
|
+
conn.authMatchKind = resolved.matchKind;
|
|
595
703
|
// Phase 1.3: index by runId for live mailbox fanout.
|
|
596
704
|
let connsForRun = this.connectionsByRun.get(runId);
|
|
597
705
|
if (!connsForRun) {
|
|
@@ -980,7 +1088,7 @@ export class CrewBroker {
|
|
|
980
1088
|
this.sendError(conn, id, "bad-params", "task.waitStatus: taskId out of range");
|
|
981
1089
|
return;
|
|
982
1090
|
}
|
|
983
|
-
const validStatuses = new Set(["queued", "running", "completed", "failed", "blocked", "cancelled"]);
|
|
1091
|
+
const validStatuses = new Set(["queued", "running", "waiting", "completed", "failed", "blocked", "cancelled"]);
|
|
984
1092
|
if (!validStatuses.has(targetStatus)) {
|
|
985
1093
|
this.sendError(conn, id, "bad-params", `task.waitStatus: invalid until '${targetStatus}'`);
|
|
986
1094
|
return;
|
|
@@ -1012,7 +1120,9 @@ export class CrewBroker {
|
|
|
1012
1120
|
return;
|
|
1013
1121
|
}
|
|
1014
1122
|
try {
|
|
1015
|
-
|
|
1123
|
+
// R10-3: stat-gated cache — parse only when manifest/tasks
|
|
1124
|
+
// mtime/size changed; identical observable behavior per poll.
|
|
1125
|
+
const loaded = this.waitStatusCache.load(cwd, connRunId);
|
|
1016
1126
|
if (!loaded) {
|
|
1017
1127
|
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1018
1128
|
resolve();
|
|
@@ -1184,6 +1294,660 @@ export class CrewBroker {
|
|
|
1184
1294
|
this.sendError(conn, id, "escalate-failed", (err as Error).message);
|
|
1185
1295
|
}
|
|
1186
1296
|
}
|
|
1297
|
+
|
|
1298
|
+
// ------------------------------------------------------------------------
|
|
1299
|
+
// WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
|
|
1300
|
+
// ------------------------------------------------------------------------
|
|
1301
|
+
|
|
1302
|
+
/** Shared auth for wait.*: worker role + task-scoped (compound-key) token
|
|
1303
|
+
* ONLY (ADR item 6). A legacy bare-runId fallback match is REJECTED with
|
|
1304
|
+
* a migrate hint; the orchestrator token is rejected by role. Returns the
|
|
1305
|
+
* error to send, or null when auth passes. */
|
|
1306
|
+
private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
|
|
1307
|
+
if (conn.role !== "worker" || conn.authMatchKind === undefined) {
|
|
1308
|
+
return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
|
|
1309
|
+
}
|
|
1310
|
+
if (conn.authMatchKind !== "compound") {
|
|
1311
|
+
return {
|
|
1312
|
+
code: "forbidden",
|
|
1313
|
+
message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** ADR item 7: a disabled-gate rejection MUST leave a durable trace in
|
|
1320
|
+
* events.jsonl — the gate fails CLOSED but never SILENTLY. Fire-and-forget
|
|
1321
|
+
* async append (broker handlers must not block the event loop on the sync
|
|
1322
|
+
* event-log lock); an append failure is logged, never thrown. */
|
|
1323
|
+
// T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
|
|
1324
|
+
// grandchild spawn with durable mailbox delivery (WP-5 step 5).
|
|
1325
|
+
private getDelegateNestedSlots(): NestedSlotBudget {
|
|
1326
|
+
if (!this.nestedSlots) {
|
|
1327
|
+
this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
|
|
1328
|
+
}
|
|
1329
|
+
return this.nestedSlots;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
private recordDelegateEvent(
|
|
1333
|
+
manifest: { eventsPath: string; runId: string },
|
|
1334
|
+
type:
|
|
1335
|
+
| "delegate.requested"
|
|
1336
|
+
| "delegate.admitted"
|
|
1337
|
+
| "delegate.rejected"
|
|
1338
|
+
| "delegate.completed"
|
|
1339
|
+
| "delegate.timed_out"
|
|
1340
|
+
| "delegate.rolled_up",
|
|
1341
|
+
taskId: string,
|
|
1342
|
+
data: Record<string, unknown>,
|
|
1343
|
+
): void {
|
|
1344
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
1345
|
+
type,
|
|
1346
|
+
runId: manifest.runId,
|
|
1347
|
+
taskId,
|
|
1348
|
+
message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
|
|
1349
|
+
data,
|
|
1350
|
+
}).catch((err) =>
|
|
1351
|
+
logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1356
|
+
// Auth: task-scoped token MANDATORY (ADR-5 pin iii) — same rule as wait.*.
|
|
1357
|
+
if (!conn.runId || !conn.taskId) {
|
|
1358
|
+
this.sendError(conn, id, "auth", "not authed");
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (conn.role !== "worker" || conn.authMatchKind !== "compound") {
|
|
1362
|
+
this.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const p = (params ?? {}) as {
|
|
1366
|
+
description?: unknown;
|
|
1367
|
+
prompt?: unknown;
|
|
1368
|
+
role?: unknown;
|
|
1369
|
+
model?: unknown;
|
|
1370
|
+
maxTurns?: unknown;
|
|
1371
|
+
budgetTokens?: unknown;
|
|
1372
|
+
timeoutSec?: unknown;
|
|
1373
|
+
};
|
|
1374
|
+
if (typeof p.prompt !== "string" || p.prompt.trim().length === 0) {
|
|
1375
|
+
this.sendError(conn, id, "bad-params", "delegate.request: 'prompt' (non-empty string) is required");
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const requested = {
|
|
1379
|
+
prompt: p.prompt,
|
|
1380
|
+
...(typeof p.description === "string" ? { description: p.description } : {}),
|
|
1381
|
+
...(typeof p.role === "string" ? { role: p.role } : {}),
|
|
1382
|
+
...(typeof p.model === "string" ? { model: p.model } : {}),
|
|
1383
|
+
...(typeof p.maxTurns === "number" ? { maxTurns: p.maxTurns } : {}),
|
|
1384
|
+
...(typeof p.budgetTokens === "number" ? { budgetTokens: p.budgetTokens } : {}),
|
|
1385
|
+
...(typeof p.timeoutSec === "number" ? { timeoutSec: p.timeoutSec } : {}),
|
|
1386
|
+
};
|
|
1387
|
+
const cwd = this.options.cwd;
|
|
1388
|
+
if (!cwd) {
|
|
1389
|
+
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
1393
|
+
try {
|
|
1394
|
+
const l = loadRunManifestById(cwd, conn.runId);
|
|
1395
|
+
if (!l) {
|
|
1396
|
+
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
loaded = l;
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
// Capability gate (ADR-5 §10): fail-closed, NEVER silent.
|
|
1405
|
+
if (this.options.nestingEnabled !== true) {
|
|
1406
|
+
this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
|
|
1407
|
+
reason: "nesting-disabled",
|
|
1408
|
+
policy: "nesting.enabled=false (fail-closed default)",
|
|
1409
|
+
});
|
|
1410
|
+
this.sendError(
|
|
1411
|
+
conn,
|
|
1412
|
+
id,
|
|
1413
|
+
"policy-disabled",
|
|
1414
|
+
"delegate is disabled: nesting.enabled=false (fail-closed default; delegate.rejected recorded in events.jsonl)",
|
|
1415
|
+
);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const runId = conn.runId;
|
|
1419
|
+
const parentTaskId = conn.taskId;
|
|
1420
|
+
// S1#1 fix (security round 1): the subId is minted BEFORE admission so the
|
|
1421
|
+
// grandchild token scopes to the SUBID (never the parent task key) and the
|
|
1422
|
+
// nested slot is acquired INSIDE the same lock as the admission snapshot.
|
|
1423
|
+
const subId = `gc-${randomUUID()}`;
|
|
1424
|
+
this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
|
|
1425
|
+
// Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
|
|
1426
|
+
// the RECORD under the run lock — never the worker's env/self-report.
|
|
1427
|
+
const admissionOutcome = withRunLockSync(loaded.manifest, () => {
|
|
1428
|
+
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1429
|
+
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1430
|
+
const task = fresh.tasks.find((t) => t.id === parentTaskId);
|
|
1431
|
+
if (!task) {
|
|
1432
|
+
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
|
|
1433
|
+
return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
|
|
1434
|
+
}
|
|
1435
|
+
if (task.status !== "running") {
|
|
1436
|
+
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
1437
|
+
subId,
|
|
1438
|
+
reason: "parent-not-running",
|
|
1439
|
+
message: `task is ${task.status}`,
|
|
1440
|
+
});
|
|
1441
|
+
return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
|
|
1442
|
+
}
|
|
1443
|
+
const catalog = this.options.modelCatalog?.();
|
|
1444
|
+
// S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
|
|
1445
|
+
const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
|
|
1446
|
+
// ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
|
|
1447
|
+
// parent task's cwd (manifest record — single source of truth).
|
|
1448
|
+
const overlapping = fresh.tasks.filter(
|
|
1449
|
+
(t) =>
|
|
1450
|
+
t.id !== parentTaskId &&
|
|
1451
|
+
t.status === "running" &&
|
|
1452
|
+
(t.role === "executor" || t.role === "test-engineer") &&
|
|
1453
|
+
t.cwd === task.cwd,
|
|
1454
|
+
).length;
|
|
1455
|
+
const decision = evaluateDelegateAdmission({
|
|
1456
|
+
nestingEnabled: true, // flag already checked above
|
|
1457
|
+
maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 2 (ADR-5 §3)
|
|
1458
|
+
parentTask: {
|
|
1459
|
+
taskId: parentTaskId,
|
|
1460
|
+
role: task.role,
|
|
1461
|
+
...(task.depth !== undefined ? { depth: task.depth } : {}),
|
|
1462
|
+
...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
|
|
1463
|
+
},
|
|
1464
|
+
slots: this.getDelegateNestedSlots().snapshot(),
|
|
1465
|
+
requested,
|
|
1466
|
+
...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
|
|
1467
|
+
// ADR-5 §12: the delegate surface is an escalation — trusted only by the
|
|
1468
|
+
// explicit user opt-in threaded from config.nesting.enabled (sensitive).
|
|
1469
|
+
untrusted: this.options.nestingTrustedEscalation !== true,
|
|
1470
|
+
workspace: {
|
|
1471
|
+
serializeEnabled: this.options.serializeOnPathOverlap === true,
|
|
1472
|
+
overlappingInFlightExecutors: overlapping,
|
|
1473
|
+
},
|
|
1474
|
+
});
|
|
1475
|
+
if (!decision.allowed) {
|
|
1476
|
+
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
1477
|
+
subId,
|
|
1478
|
+
reason: decision.reason,
|
|
1479
|
+
message: (decision.message ?? "").slice(0, 120),
|
|
1480
|
+
});
|
|
1481
|
+
return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
|
|
1482
|
+
}
|
|
1483
|
+
// Slot acquisition INSIDE the lock (no reserve-then-race refund window).
|
|
1484
|
+
if (!this.getDelegateNestedSlots().tryAcquire(subId)) {
|
|
1485
|
+
this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
|
|
1486
|
+
return {
|
|
1487
|
+
code: "policy-denied" as const,
|
|
1488
|
+
message: `delegate rejected: nested spawn budget exhausted; ${this.getDelegateNestedSlots().statusLine}`,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
// Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
|
|
1492
|
+
// += budgetTokens now; the completion roll-up reconciles to actual usage
|
|
1493
|
+
// (refund the difference). Single writer under the run lock.
|
|
1494
|
+
let reserved = 0;
|
|
1495
|
+
const parentAllocation = task.allocation;
|
|
1496
|
+
let tasksAfterReserve = fresh.tasks;
|
|
1497
|
+
if (requested.budgetTokens !== undefined && parentAllocation) {
|
|
1498
|
+
reserved = requested.budgetTokens;
|
|
1499
|
+
const updatedTasks = fresh.tasks.map((t) =>
|
|
1500
|
+
t.id === parentTaskId
|
|
1501
|
+
? {
|
|
1502
|
+
...t,
|
|
1503
|
+
allocation: {
|
|
1504
|
+
tokensGranted: parentAllocation.tokensGranted,
|
|
1505
|
+
tokensSpent: (parentAllocation.tokensSpent ?? 0) + reserved,
|
|
1506
|
+
},
|
|
1507
|
+
}
|
|
1508
|
+
: t,
|
|
1509
|
+
);
|
|
1510
|
+
tasksAfterReserve = updatedTasks;
|
|
1511
|
+
saveRunTasks(fresh.manifest, updatedTasks);
|
|
1512
|
+
}
|
|
1513
|
+
// S1#1: register the grandchild SHADOW TASK — the subId identity gets a
|
|
1514
|
+
// real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
|
|
1515
|
+
// so the budget reservation above is never clobbered.
|
|
1516
|
+
saveRunTasks(fresh.manifest, [
|
|
1517
|
+
...tasksAfterReserve,
|
|
1518
|
+
{
|
|
1519
|
+
id: subId,
|
|
1520
|
+
runId,
|
|
1521
|
+
role: requested.role ?? "explorer",
|
|
1522
|
+
agent: "delegate",
|
|
1523
|
+
title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
|
|
1524
|
+
status: "queued",
|
|
1525
|
+
cwd: task.cwd,
|
|
1526
|
+
dependsOn: [],
|
|
1527
|
+
depth: decision.childDepth ?? 2,
|
|
1528
|
+
startedAt: new Date().toISOString(),
|
|
1529
|
+
} satisfies TeamTaskState,
|
|
1530
|
+
]);
|
|
1531
|
+
return { code: "ok" as const, decision, reserved };
|
|
1532
|
+
});
|
|
1533
|
+
if (admissionOutcome.code !== "ok") {
|
|
1534
|
+
this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const { decision, reserved } = admissionOutcome;
|
|
1538
|
+
this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
|
|
1539
|
+
subId,
|
|
1540
|
+
childDepth: decision.childDepth,
|
|
1541
|
+
role: requested.role ?? "explorer",
|
|
1542
|
+
...(reserved > 0 ? { reservedTokens: reserved } : {}),
|
|
1543
|
+
});
|
|
1544
|
+
// S1#1: pre-mint the grandchild-scoped token when (and only when) the
|
|
1545
|
+
// child may itself delegate (childDepth < resolved maxDepth — the same
|
|
1546
|
+
// gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
|
|
1547
|
+
const grandchildCreds =
|
|
1548
|
+
(decision.childDepth ?? 2) < (this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
|
|
1549
|
+
? { socketPath: this.socketPath, token: this.issueRunToken(runId, subId) }
|
|
1550
|
+
: undefined;
|
|
1551
|
+
// Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
|
|
1552
|
+
this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
|
|
1553
|
+
// Background grandchild lifecycle (ADR-5 §1/§6).
|
|
1554
|
+
const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
|
|
1555
|
+
void (async () => {
|
|
1556
|
+
let outcome: GrandchildSpawnResult;
|
|
1557
|
+
try {
|
|
1558
|
+
outcome = await spawner({
|
|
1559
|
+
cwd,
|
|
1560
|
+
runId,
|
|
1561
|
+
parentTaskId,
|
|
1562
|
+
subId,
|
|
1563
|
+
prompt: requested.prompt,
|
|
1564
|
+
eventsPath: loaded.manifest.eventsPath,
|
|
1565
|
+
role: requested.role ?? "explorer",
|
|
1566
|
+
...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
|
|
1567
|
+
...(requested.model !== undefined ? { model: requested.model } : {}),
|
|
1568
|
+
...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
|
|
1569
|
+
timeoutSec: decision.timeoutSec ?? 900,
|
|
1570
|
+
depthOverride: decision.childDepth ?? 2,
|
|
1571
|
+
});
|
|
1572
|
+
} catch (err) {
|
|
1573
|
+
outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
|
|
1574
|
+
}
|
|
1575
|
+
// S3 fence hardening: neutralize smuggled end-fence markers in the
|
|
1576
|
+
// grandchild's raw text, then wrap (the client re-fences too — the
|
|
1577
|
+
// mailbox is an unauthenticated same-uid channel by design).
|
|
1578
|
+
const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
|
|
1579
|
+
const fenced = `--- delegate ${subId} ${outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)"} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
|
|
1580
|
+
// Durable delivery (ADR-5 §1): fenced result into the PARENT task's
|
|
1581
|
+
// mailbox + budget roll-up + slot release — run-locked RMW.
|
|
1582
|
+
try {
|
|
1583
|
+
const fresh = loadRunManifestById(cwd, runId);
|
|
1584
|
+
if (fresh) {
|
|
1585
|
+
withRunLockSync(fresh.manifest, () => {
|
|
1586
|
+
const latest = loadRunManifestById(cwd, runId);
|
|
1587
|
+
if (!latest) return;
|
|
1588
|
+
void appendMailboxMessageAsync(latest.manifest, {
|
|
1589
|
+
direction: "inbox",
|
|
1590
|
+
from: `delegate:${subId}`,
|
|
1591
|
+
to: parentTaskId,
|
|
1592
|
+
taskId: parentTaskId,
|
|
1593
|
+
body: fenced,
|
|
1594
|
+
kind: "response",
|
|
1595
|
+
data: { subId, ok: outcome.ok, timedOut: outcome.timedOut === true },
|
|
1596
|
+
}).catch((err) =>
|
|
1597
|
+
logInternalError(
|
|
1598
|
+
"crew-broker.delegate.mailbox",
|
|
1599
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
1600
|
+
`runId=${runId}`,
|
|
1601
|
+
),
|
|
1602
|
+
);
|
|
1603
|
+
// Roll-up (ADR-5 §5): reconcile the pessimistic reservation to
|
|
1604
|
+
// actual usage; refund the difference. Unattributed → keep reserve.
|
|
1605
|
+
let tasksToWrite = latest.tasks;
|
|
1606
|
+
if (reserved > 0) {
|
|
1607
|
+
const parent = latest.tasks.find((t) => t.id === parentTaskId);
|
|
1608
|
+
const parentAlloc = parent?.allocation;
|
|
1609
|
+
if (parentAlloc) {
|
|
1610
|
+
const actual = Math.max(0, Math.min(outcome.usageTokens ?? reserved, reserved));
|
|
1611
|
+
const tokensSpent = (parentAlloc.tokensSpent ?? 0) - reserved + actual;
|
|
1612
|
+
tasksToWrite = tasksToWrite.map((t) =>
|
|
1613
|
+
t.id === parentTaskId
|
|
1614
|
+
? { ...t, allocation: { tokensGranted: parentAlloc.tokensGranted, tokensSpent } }
|
|
1615
|
+
: t,
|
|
1616
|
+
);
|
|
1617
|
+
this.recordDelegateEvent(latest.manifest, "delegate.rolled_up", parentTaskId, {
|
|
1618
|
+
subId,
|
|
1619
|
+
actualTokens: actual,
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
// S1#1: flip the shadow task to terminal — UNCONDITIONAL (verifier N1): the
|
|
1624
|
+
// flip must not depend on a reservation; no allocation producer ships
|
|
1625
|
+
// yet so reserved===0 is the common case and the guard left every
|
|
1626
|
+
// shadow "queued" forever.
|
|
1627
|
+
// S1#1: flip the shadow task to terminal so the subId record does not
|
|
1628
|
+
// linger as "queued" in team status views.
|
|
1629
|
+
saveRunTasks(
|
|
1630
|
+
latest.manifest,
|
|
1631
|
+
tasksToWrite.map((t) =>
|
|
1632
|
+
t.id === subId
|
|
1633
|
+
? {
|
|
1634
|
+
...t,
|
|
1635
|
+
status: outcome.ok ? ("completed" as const) : ("failed" as const),
|
|
1636
|
+
completedAt: new Date().toISOString(),
|
|
1637
|
+
}
|
|
1638
|
+
: t,
|
|
1639
|
+
),
|
|
1640
|
+
);
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
logInternalError("crew-broker.delegate.finalize", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`);
|
|
1645
|
+
// P2-8c: best-effort refund when finalize fails — the reservation
|
|
1646
|
+
// must not linger inflated after a roll-up write failure.
|
|
1647
|
+
if (reserved > 0) {
|
|
1648
|
+
try {
|
|
1649
|
+
const rf = loadRunManifestById(cwd, runId);
|
|
1650
|
+
if (rf) {
|
|
1651
|
+
withRunLockSync(rf.manifest, () => {
|
|
1652
|
+
const rl = loadRunManifestById(cwd, runId);
|
|
1653
|
+
if (!rl) return;
|
|
1654
|
+
const par = rl.tasks.find((t) => t.id === parentTaskId);
|
|
1655
|
+
const pa = par?.allocation;
|
|
1656
|
+
if (pa) {
|
|
1657
|
+
saveRunTasks(
|
|
1658
|
+
rl.manifest,
|
|
1659
|
+
rl.tasks.map((t) =>
|
|
1660
|
+
t.id === parentTaskId
|
|
1661
|
+
? {
|
|
1662
|
+
...t,
|
|
1663
|
+
allocation: {
|
|
1664
|
+
tokensGranted: pa.tokensGranted,
|
|
1665
|
+
tokensSpent: Math.max(0, (pa.tokensSpent ?? 0) - reserved),
|
|
1666
|
+
},
|
|
1667
|
+
}
|
|
1668
|
+
: t,
|
|
1669
|
+
),
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
} catch {
|
|
1675
|
+
/* refund is best-effort; the startup reconcile is the follow-up */
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
} finally {
|
|
1679
|
+
this.getDelegateNestedSlots().release(subId);
|
|
1680
|
+
}
|
|
1681
|
+
this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
|
|
1682
|
+
subId,
|
|
1683
|
+
ok: outcome.ok,
|
|
1684
|
+
});
|
|
1685
|
+
})();
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
private recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
|
|
1689
|
+
const runId = manifest.runId;
|
|
1690
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
1691
|
+
type: "policy.action",
|
|
1692
|
+
runId,
|
|
1693
|
+
taskId,
|
|
1694
|
+
message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
|
|
1695
|
+
data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
|
|
1696
|
+
}).catch((err) =>
|
|
1697
|
+
logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
/** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
|
|
1702
|
+
* leader answer. Park = task.status "waiting" + task.waiting marker +
|
|
1703
|
+
* manifest.waitState pointer; manifest.status NEVER flips (stays
|
|
1704
|
+
* "running" — registry entry, sidebar visibility and
|
|
1705
|
+
* live-executor.isCurrent() all preserved; ADR item 3). Writes happen
|
|
1706
|
+
* under withRunLockSync with a fresh reload (respond.ts:42-43 discipline).
|
|
1707
|
+
* deadline = now + min(timeoutSec, 3600): the SERVER clamps —
|
|
1708
|
+
* worker-controlled timeoutSec may never exceed 1h (ADR P2-7). */
|
|
1709
|
+
private async handleWaitRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1710
|
+
if (!conn.runId || !conn.taskId) {
|
|
1711
|
+
this.sendError(conn, id, "auth", "not authed");
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const authErr = this.waitAuthError(conn);
|
|
1715
|
+
if (authErr) {
|
|
1716
|
+
this.sendError(conn, id, authErr.code, authErr.message);
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const parsed = parseWaitRequestParams(params);
|
|
1720
|
+
if (!parsed) {
|
|
1721
|
+
this.sendError(conn, id, "bad-params", "wait.request: invalid params");
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
// Server-side identity enforcement: `to` MUST equal the authenticated
|
|
1725
|
+
// task — a worker may only park ITSELF. (The escalate handler's
|
|
1726
|
+
// unvalidated `to` is the recorded anti-pattern; do NOT replicate.)
|
|
1727
|
+
if (parsed.to !== conn.taskId) {
|
|
1728
|
+
this.sendError(conn, id, "forbidden", "wait.request: 'to' must match the authenticated task");
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
const cwd = this.options.cwd;
|
|
1732
|
+
if (!cwd) {
|
|
1733
|
+
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
1737
|
+
try {
|
|
1738
|
+
const l = loadRunManifestById(cwd, conn.runId);
|
|
1739
|
+
if (!l) {
|
|
1740
|
+
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
loaded = l;
|
|
1744
|
+
} catch (err) {
|
|
1745
|
+
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
// Capability gate (ADR item 7): fail-closed, NEVER silent — every
|
|
1749
|
+
// rejection leaves a policy.action trace in the run's events.jsonl.
|
|
1750
|
+
if (this.options.waitMethodsEnabled !== true) {
|
|
1751
|
+
this.recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.request");
|
|
1752
|
+
this.sendError(
|
|
1753
|
+
conn,
|
|
1754
|
+
id,
|
|
1755
|
+
"policy-disabled",
|
|
1756
|
+
"wait.request is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
|
|
1757
|
+
);
|
|
1758
|
+
return;
|
|
1759
|
+
}
|
|
1760
|
+
// Server clamp BEFORE any state write (ADR P2-7).
|
|
1761
|
+
const requestedSec = parsed.timeoutSec ?? WAIT_REQUEST_TIMEOUT_SEC_DEFAULT;
|
|
1762
|
+
const clampSec = Math.min(Math.max(1, Math.floor(requestedSec)), WAIT_REQUEST_TIMEOUT_SEC_MAX);
|
|
1763
|
+
const clamped = requestedSec > clampSec;
|
|
1764
|
+
const questionId = randomUUID();
|
|
1765
|
+
const askedAt = new Date().toISOString();
|
|
1766
|
+
const deadline = Date.now() + clampSec * 1000;
|
|
1767
|
+
const runId = conn.runId;
|
|
1768
|
+
const taskId = conn.taskId;
|
|
1769
|
+
const outcome = withRunLockSync(loaded.manifest, () => {
|
|
1770
|
+
// Fresh reload INSIDE the lock (respond.ts:42-43 discipline).
|
|
1771
|
+
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1772
|
+
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1773
|
+
const task = fresh.tasks.find((t) => t.id === taskId);
|
|
1774
|
+
if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
|
|
1775
|
+
if (task.status !== "running") {
|
|
1776
|
+
return { code: "bad-params" as const, message: `wait.request: task '${taskId}' is ${task.status}, not running` };
|
|
1777
|
+
}
|
|
1778
|
+
const updatedTasks = fresh.tasks.map((t) =>
|
|
1779
|
+
t.id === taskId
|
|
1780
|
+
? {
|
|
1781
|
+
...t,
|
|
1782
|
+
status: "waiting" as const,
|
|
1783
|
+
waiting: {
|
|
1784
|
+
questionId,
|
|
1785
|
+
askedAt,
|
|
1786
|
+
deadline,
|
|
1787
|
+
...(parsed.options ? { options: parsed.options } : {}),
|
|
1788
|
+
},
|
|
1789
|
+
}
|
|
1790
|
+
: t,
|
|
1791
|
+
);
|
|
1792
|
+
const updatedManifest = {
|
|
1793
|
+
...fresh.manifest,
|
|
1794
|
+
// manifest.status stays "running" — park NEVER flips run status (ADR item 3).
|
|
1795
|
+
waitState: { taskId, questionId, askedAt },
|
|
1796
|
+
updatedAt: askedAt,
|
|
1797
|
+
};
|
|
1798
|
+
saveRunTasks(updatedManifest, updatedTasks);
|
|
1799
|
+
saveRunManifest(updatedManifest);
|
|
1800
|
+
return { code: "ok" as const, message: "" };
|
|
1801
|
+
});
|
|
1802
|
+
if (outcome.code !== "ok") {
|
|
1803
|
+
this.sendError(conn, id, outcome.code, outcome.message);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
// Events AFTER the run lock is released (the event-log lock is a
|
|
1807
|
+
// separate lock; no cross-lock ordering). task.waiting mirrors the
|
|
1808
|
+
// persisted status flip for event-log reconstruction (tasks.json
|
|
1809
|
+
// corruption recovery); ask.requested carries the question for the
|
|
1810
|
+
// leader/UI. Fire-and-forget: an event failure must not fail the park.
|
|
1811
|
+
const eventsPath = loaded.manifest.eventsPath;
|
|
1812
|
+
void appendEventAsync(eventsPath, {
|
|
1813
|
+
type: "task.waiting",
|
|
1814
|
+
runId,
|
|
1815
|
+
taskId,
|
|
1816
|
+
message: `Task parked awaiting leader answer (question ${questionId}, deadline in ${clampSec}s).`,
|
|
1817
|
+
data: { questionId, deadline },
|
|
1818
|
+
}).catch((err) =>
|
|
1819
|
+
logInternalError("crew-broker.wait.task-waiting-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1820
|
+
);
|
|
1821
|
+
void appendEventAsync(eventsPath, {
|
|
1822
|
+
type: "ask.requested",
|
|
1823
|
+
runId,
|
|
1824
|
+
taskId,
|
|
1825
|
+
message: parsed.question,
|
|
1826
|
+
data: {
|
|
1827
|
+
questionId,
|
|
1828
|
+
deadline,
|
|
1829
|
+
timeoutSec: clampSec,
|
|
1830
|
+
clamped,
|
|
1831
|
+
...(parsed.options ? { options: parsed.options } : {}),
|
|
1832
|
+
},
|
|
1833
|
+
}).catch((err) =>
|
|
1834
|
+
logInternalError("crew-broker.wait.ask-requested-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1835
|
+
);
|
|
1836
|
+
this.sendResult(conn, id, {
|
|
1837
|
+
ok: true,
|
|
1838
|
+
questionId,
|
|
1839
|
+
askedAt,
|
|
1840
|
+
deadline,
|
|
1841
|
+
timeoutSec: clampSec,
|
|
1842
|
+
clamped,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/** WP-2/R2: terminal report of the parked `ask` tool — flips the task
|
|
1847
|
+
* waiting→running and clears the park coordination state. Scoped to
|
|
1848
|
+
* auth + state transition + ask.answered event ONLY (ADR item 6/8):
|
|
1849
|
+
* answer DELIVERY is the mailbox respond path (step 6), not this
|
|
1850
|
+
* method. A questionId mismatch (or a task that is not parked) is
|
|
1851
|
+
* rejected WITHOUT clearing anything — fail-closed. */
|
|
1852
|
+
private async handleWaitResolve(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1853
|
+
if (!conn.runId || !conn.taskId) {
|
|
1854
|
+
this.sendError(conn, id, "auth", "not authed");
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
const authErr = this.waitAuthError(conn);
|
|
1858
|
+
if (authErr) {
|
|
1859
|
+
this.sendError(conn, id, authErr.code, authErr.message);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
const parsed = parseWaitResolveParams(params);
|
|
1863
|
+
if (!parsed) {
|
|
1864
|
+
this.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
// Server-side identity enforcement (same rule as wait.request).
|
|
1868
|
+
if (parsed.to !== conn.taskId) {
|
|
1869
|
+
this.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const cwd = this.options.cwd;
|
|
1873
|
+
if (!cwd) {
|
|
1874
|
+
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
1878
|
+
try {
|
|
1879
|
+
const l = loadRunManifestById(cwd, conn.runId);
|
|
1880
|
+
if (!l) {
|
|
1881
|
+
this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
loaded = l;
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
this.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
if (this.options.waitMethodsEnabled !== true) {
|
|
1890
|
+
this.recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
|
|
1891
|
+
this.sendError(
|
|
1892
|
+
conn,
|
|
1893
|
+
id,
|
|
1894
|
+
"policy-disabled",
|
|
1895
|
+
"wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
|
|
1896
|
+
);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
const runId = conn.runId;
|
|
1900
|
+
const taskId = conn.taskId;
|
|
1901
|
+
const outcome = withRunLockSync(loaded.manifest, () => {
|
|
1902
|
+
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
1903
|
+
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
1904
|
+
const task = fresh.tasks.find((t) => t.id === taskId);
|
|
1905
|
+
if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
|
|
1906
|
+
if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
|
|
1907
|
+
return {
|
|
1908
|
+
code: "bad-params" as const,
|
|
1909
|
+
message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
|
|
1913
|
+
// waitState is a single run-level slot: clear it ONLY when it points
|
|
1914
|
+
// at this exact question — never clobber another task's newer park.
|
|
1915
|
+
const waitState = fresh.manifest.waitState;
|
|
1916
|
+
const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
|
|
1917
|
+
const updatedManifest = {
|
|
1918
|
+
...fresh.manifest,
|
|
1919
|
+
...(clearWaitState ? { waitState: undefined } : {}),
|
|
1920
|
+
updatedAt: new Date().toISOString(),
|
|
1921
|
+
};
|
|
1922
|
+
saveRunTasks(updatedManifest, updatedTasks);
|
|
1923
|
+
saveRunManifest(updatedManifest);
|
|
1924
|
+
return { code: "ok" as const, message: "" };
|
|
1925
|
+
});
|
|
1926
|
+
if (outcome.code !== "ok") {
|
|
1927
|
+
this.sendError(conn, id, outcome.code, outcome.message);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const eventsPath = loaded.manifest.eventsPath;
|
|
1931
|
+
void appendEventAsync(eventsPath, {
|
|
1932
|
+
type: "ask.answered",
|
|
1933
|
+
runId,
|
|
1934
|
+
taskId,
|
|
1935
|
+
message: `Question ${parsed.questionId} answered; task resumed.`,
|
|
1936
|
+
data: { questionId: parsed.questionId },
|
|
1937
|
+
}).catch((err) =>
|
|
1938
|
+
logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1939
|
+
);
|
|
1940
|
+
void appendEventAsync(eventsPath, {
|
|
1941
|
+
type: "task.resumed",
|
|
1942
|
+
runId,
|
|
1943
|
+
taskId,
|
|
1944
|
+
message: `Task resumed after ask answer (question ${parsed.questionId}).`,
|
|
1945
|
+
data: { questionId: parsed.questionId },
|
|
1946
|
+
}).catch((err) =>
|
|
1947
|
+
logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
|
|
1948
|
+
);
|
|
1949
|
+
this.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
|
|
1950
|
+
}
|
|
1187
1951
|
}
|
|
1188
1952
|
|
|
1189
1953
|
// ============================================================================
|
|
@@ -1278,3 +2042,67 @@ function safeStringify(value: unknown): string {
|
|
|
1278
2042
|
return "{}";
|
|
1279
2043
|
}
|
|
1280
2044
|
}
|
|
2045
|
+
|
|
2046
|
+
// ============================================================================
|
|
2047
|
+
// WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
|
|
2048
|
+
// ============================================================================
|
|
2049
|
+
|
|
2050
|
+
/** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
|
|
2051
|
+
* timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
|
|
2052
|
+
* amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
|
|
2053
|
+
const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
|
|
2054
|
+
/** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
|
|
2055
|
+
const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
|
|
2056
|
+
/** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
|
|
2057
|
+
const WAIT_QUESTION_MAX_CHARS = 8192;
|
|
2058
|
+
/** Bounded answer-choice list: at most 16 options, 256 chars each. */
|
|
2059
|
+
const WAIT_OPTIONS_MAX = 16;
|
|
2060
|
+
const WAIT_OPTION_MAX_CHARS = 256;
|
|
2061
|
+
|
|
2062
|
+
interface WaitRequestParams {
|
|
2063
|
+
to: string;
|
|
2064
|
+
question: string;
|
|
2065
|
+
options?: string[];
|
|
2066
|
+
timeoutSec?: number;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
|
|
2070
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2071
|
+
const v = value as Record<string, unknown>;
|
|
2072
|
+
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
2073
|
+
if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
|
|
2074
|
+
return undefined;
|
|
2075
|
+
}
|
|
2076
|
+
let options: string[] | undefined;
|
|
2077
|
+
if (v.options !== undefined) {
|
|
2078
|
+
if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
|
|
2079
|
+
for (const o of v.options) {
|
|
2080
|
+
if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
|
|
2081
|
+
}
|
|
2082
|
+
options = v.options as string[];
|
|
2083
|
+
}
|
|
2084
|
+
// timeoutSec is clamped server-side in the handler (max 3600); the parser
|
|
2085
|
+
// only rejects non-finite values. Non-positive values clamp to 1s.
|
|
2086
|
+
if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
|
|
2087
|
+
return undefined;
|
|
2088
|
+
}
|
|
2089
|
+
return {
|
|
2090
|
+
to: v.to,
|
|
2091
|
+
question: v.question,
|
|
2092
|
+
options,
|
|
2093
|
+
timeoutSec: v.timeoutSec as number | undefined,
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
interface WaitResolveParams {
|
|
2098
|
+
to: string;
|
|
2099
|
+
questionId: string;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
|
|
2103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2104
|
+
const v = value as Record<string, unknown>;
|
|
2105
|
+
if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
|
|
2106
|
+
if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
|
|
2107
|
+
return { to: v.to, questionId: v.questionId };
|
|
2108
|
+
}
|