@byok-sdk/client 0.1.0
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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/adapters/claude/claude-adapter.d.ts +155 -0
- package/dist/adapters/claude/events.d.ts +91 -0
- package/dist/adapters/claude/permission-mapping.d.ts +142 -0
- package/dist/adapters/claude/process-client.d.ts +103 -0
- package/dist/adapters/claude/resolve-approval-mcp-bin.d.ts +45 -0
- package/dist/adapters/claude/resolve-bin.d.ts +27 -0
- package/dist/adapters/codex/codex-adapter.d.ts +97 -0
- package/dist/adapters/codex/events.d.ts +41 -0
- package/dist/adapters/codex/permission-mapping.d.ts +107 -0
- package/dist/adapters/codex/process-runner.d.ts +86 -0
- package/dist/adapters/codex/resolve-bin.d.ts +25 -0
- package/dist/adapters/pi/events.d.ts +58 -0
- package/dist/adapters/pi/permission-mapping.d.ts +46 -0
- package/dist/adapters/pi/pi-adapter.d.ts +29 -0
- package/dist/adapters/pi/resolve-bin.d.ts +44 -0
- package/dist/adapters/pi/rpc-client.d.ts +101 -0
- package/dist/bin/approval-mcp-server.d.ts +69 -0
- package/dist/bin/audit-log.d.ts +212 -0
- package/dist/bin/byok-agent.d.ts +2 -0
- package/dist/bin/byok-agent.js +12347 -0
- package/dist/bin/byok-agent.js.map +1 -0
- package/dist/bin/byok-approval-mcp.d.ts +2 -0
- package/dist/bin/byok-approval-mcp.js +498 -0
- package/dist/bin/byok-approval-mcp.js.map +1 -0
- package/dist/bin/commands/approvals.d.ts +31 -0
- package/dist/bin/commands/approve-reject.d.ts +29 -0
- package/dist/bin/commands/doctor.d.ts +15 -0
- package/dist/bin/commands/pair.d.ts +7 -0
- package/dist/bin/commands/runtimes.d.ts +7 -0
- package/dist/bin/commands/service.d.ts +54 -0
- package/dist/bin/commands/start.d.ts +21 -0
- package/dist/bin/commands/status.d.ts +25 -0
- package/dist/bin/commands/support-bundle.d.ts +7 -0
- package/dist/bin/commands/tasks.d.ts +27 -0
- package/dist/bin/commands/unpair.d.ts +124 -0
- package/dist/bin/commands/workspaces.d.ts +16 -0
- package/dist/bin/config.d.ts +41 -0
- package/dist/bin/control-client.d.ts +50 -0
- package/dist/bin/format.d.ts +67 -0
- package/dist/bin/runtime-probe.d.ts +34 -0
- package/dist/bin/tasks-view.d.ts +49 -0
- package/dist/daemon/approvals.d.ts +87 -0
- package/dist/daemon/auth-manager.d.ts +52 -0
- package/dist/daemon/blob-client.d.ts +22 -0
- package/dist/daemon/connection-manager.d.ts +502 -0
- package/dist/daemon/control-protocol.d.ts +299 -0
- package/dist/daemon/control-server.d.ts +61 -0
- package/dist/daemon/create-daemon.d.ts +364 -0
- package/dist/daemon/cursor-store.d.ts +30 -0
- package/dist/daemon/daemon-owner.d.ts +24 -0
- package/dist/daemon/deterministic-jitter.d.ts +20 -0
- package/dist/daemon/device-keys.d.ts +45 -0
- package/dist/daemon/device-proof-signer.d.ts +41 -0
- package/dist/daemon/environment.d.ts +96 -0
- package/dist/daemon/git-workspace-store.d.ts +49 -0
- package/dist/daemon/git-workspace.d.ts +80 -0
- package/dist/daemon/http-client.d.ts +8 -0
- package/dist/daemon/journal/journal.d.ts +342 -0
- package/dist/daemon/journal/sqlite-journal.d.ts +173 -0
- package/dist/daemon/journal/sqlite-support.d.ts +96 -0
- package/dist/daemon/journal/storage-policy.d.ts +368 -0
- package/dist/daemon/long-poll-transport.d.ts +142 -0
- package/dist/daemon/observer.d.ts +345 -0
- package/dist/daemon/operational-health.d.ts +87 -0
- package/dist/daemon/policy.d.ts +16 -0
- package/dist/daemon/progress-batcher.d.ts +31 -0
- package/dist/daemon/runtime-capabilities.d.ts +36 -0
- package/dist/daemon/session-workspace-store.d.ts +75 -0
- package/dist/daemon/store.d.ts +54 -0
- package/dist/daemon/task-runner.d.ts +862 -0
- package/dist/daemon/truth-memory-client.d.ts +94 -0
- package/dist/daemon/url.d.ts +60 -0
- package/dist/daemon/ws-transport.d.ts +110 -0
- package/dist/diagnostics/diagnostics.d.ts +114 -0
- package/dist/diagnostics/support-bundle.d.ts +81 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +10056 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/create-service-lifecycle.d.ts +59 -0
- package/dist/lifecycle/exec-runner.d.ts +141 -0
- package/dist/lifecycle/launchd.d.ts +55 -0
- package/dist/lifecycle/service-types.d.ts +177 -0
- package/dist/lifecycle/systemd.d.ts +46 -0
- package/dist/lifecycle/winsw.d.ts +64 -0
- package/dist/types.d.ts +178 -0
- package/dist/util/async-queue.d.ts +45 -0
- package/dist/util/atomic-write.d.ts +86 -0
- package/dist/util/secure-dir.d.ts +124 -0
- package/package.json +66 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { type Envelope, type PermissionPolicy, type RuntimeId, type TaskOfferPayload } from '@byok-sdk/protocol';
|
|
2
|
+
import { type RuntimeAdapter } from '../types';
|
|
3
|
+
import { type ApprovalDecision, type ApprovalOrigin, type ApprovalRegistry } from './approvals';
|
|
4
|
+
import type { BlobResolver } from './blob-client';
|
|
5
|
+
import type { TaskQueueWatermark } from './control-protocol';
|
|
6
|
+
import { type ProgressBatcherOptions } from './progress-batcher';
|
|
7
|
+
import type { SessionWorkspaceStore } from './session-workspace-store';
|
|
8
|
+
import type { GitWorkspaceManager, GitWorkspaceObservation } from './git-workspace';
|
|
9
|
+
import type { GitWorkspaceStore, GitWorkspacePhase } from './git-workspace-store';
|
|
10
|
+
/**
|
|
11
|
+
* M4 Phase 3: default wait for `requestApproval` (see its own doc comment)
|
|
12
|
+
* before force-resolving an unanswered out-of-band approval as a fail-closed
|
|
13
|
+
* rejection — generous enough for a real human to actually notice and act on
|
|
14
|
+
* an approval prompt, short enough that a genuinely abandoned task doesn't
|
|
15
|
+
* tie up daemon/task bookkeeping forever. Overridable via
|
|
16
|
+
* `TaskRunnerDeps.approvalTimeoutMs` (ultimately `DaemonConfig`-configurable —
|
|
17
|
+
* see `create-daemon.ts`).
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_APPROVAL_TIMEOUT_MS: number;
|
|
20
|
+
/**
|
|
21
|
+
* Finding F5(a) (cross-model adversarial review): bound on how long
|
|
22
|
+
* `shutdownTask` waits for a single task's OWN `session.interrupt()` before
|
|
23
|
+
* giving up on it specifically and reporting `task.fail` anyway. Without an
|
|
24
|
+
* INNER bound here, a hung `interrupt()` (a misbehaving runtime adapter
|
|
25
|
+
* whose promise never settles) meant `task.fail` for THAT task was never
|
|
26
|
+
* sent at all — not eventually, not ever — because the send was sequenced
|
|
27
|
+
* strictly AFTER the `await`. The OUTER deadline
|
|
28
|
+
* `create-daemon.ts`'s `performControlShutdown` races `shutdownActiveTasks`
|
|
29
|
+
* against (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`) does not help: racing at
|
|
30
|
+
* that layer only unblocks the CALLER to proceed to `stop()`/closing the
|
|
31
|
+
* connection — it does nothing to unstick THIS function's own
|
|
32
|
+
* still-suspended `await`, which just keeps running (harmlessly, since
|
|
33
|
+
* nothing awaits it anymore) in the background forever after, its
|
|
34
|
+
* `deps.send` line never reached. Deliberately shorter than the outer
|
|
35
|
+
* 10s deadline so one hung task's own interrupt can't itself consume the
|
|
36
|
+
* whole outer budget and starve however many OTHER tasks
|
|
37
|
+
* `shutdownActiveTasks` awaits concurrently via `Promise.all`. Overridable
|
|
38
|
+
* via `TaskRunnerDeps.shutdownInterruptTimeoutMs` (ultimately
|
|
39
|
+
* `DaemonOverrides.shutdown.taskInterruptTimeoutMs` — see `create-daemon.ts`).
|
|
40
|
+
*/
|
|
41
|
+
export declare const DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS = 5000;
|
|
42
|
+
/**
|
|
43
|
+
* M4 Phase 4 (fold-in from the P3 gate): bound on how many `requestApproval`
|
|
44
|
+
* calls may sit QUEUED (not yet dispatched — see that method's own doc
|
|
45
|
+
* comment) for the same task at once. Claude's parallel tool use can fire
|
|
46
|
+
* more than one concurrent approval request for the same taskId; this is a
|
|
47
|
+
* defensive ceiling on that fan-out, mirroring `approvals.ts`'s own
|
|
48
|
+
* `MAX_PENDING_APPROVALS` (a whole-daemon cap) one level down (a per-task
|
|
49
|
+
* cap) — not a realistic workload limit. A request arriving once a task's
|
|
50
|
+
* queue is already at this size is rejected fail-closed immediately, the
|
|
51
|
+
* same shape `requestApproval` already uses for an unknown/inactive taskId.
|
|
52
|
+
*/
|
|
53
|
+
export declare const MAX_PENDING_APPROVALS_PER_TASK = 16;
|
|
54
|
+
/**
|
|
55
|
+
* M4 Phase 3 hardening (orchestrator-directed fix): thrown by the
|
|
56
|
+
* `ctx.approvalChannel.resolve` closure built in `handleOffer` below when
|
|
57
|
+
* this task has no CURRENTLY pending out-of-band approval to resolve.
|
|
58
|
+
* Distinguished from a plain `Error` specifically so `handleApprove`/
|
|
59
|
+
* `handleReject` can tell "a wire task.approve/task.reject arrived for an
|
|
60
|
+
* approval a DIFFERENT, faster path (a racing local `approvals.resolve`, or
|
|
61
|
+
* this exact decision arriving twice) already resolved" — a benign,
|
|
62
|
+
* expected race, audit-worthy but never task-state-affecting — apart from
|
|
63
|
+
* "the session's own resolveApproval() failed for some other, genuine
|
|
64
|
+
* reason" (an adapter-level problem, which still fails the task exactly as
|
|
65
|
+
* before). Only ever thrown for an adapter that actually wires up a real
|
|
66
|
+
* approval channel (claude, under `confirm` mode) — pi/codex's own
|
|
67
|
+
* `resolveApproval()` still throw their own unrelated, adapter-specific
|
|
68
|
+
* "not supported at all" errors, which are NOT instances of this class and
|
|
69
|
+
* therefore still fall through to the pre-existing fail-the-task behavior,
|
|
70
|
+
* unchanged.
|
|
71
|
+
*/
|
|
72
|
+
export declare class NoPendingApprovalError extends Error {
|
|
73
|
+
readonly taskId: string;
|
|
74
|
+
constructor(taskId: string);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* M3-B: cap for both `finishedTaskIds` and `pendingCancelled` below (each
|
|
78
|
+
* gains one entry per finished/cancelled task and was never pruned) — fine
|
|
79
|
+
* for the short-lived CLI invocations M0-M2 ran as, but M3 turns the daemon
|
|
80
|
+
* into a background service meant to stay up for weeks, so unbounded growth
|
|
81
|
+
* here is a real, if slow, memory leak. Each collection evicts its OLDEST
|
|
82
|
+
* (first-inserted) entry once over this cap — the same bounded-ring idiom
|
|
83
|
+
* `ConnectionHub`'s per-device dedup window already uses server-side
|
|
84
|
+
* (packages/server/src/hub.ts's `DEDUP_RING_CAPACITY`), just applied here to
|
|
85
|
+
* task ids. `Map`/`Set` iterate in insertion order (ECMA-262), so "oldest"
|
|
86
|
+
* always means "finished/cancelled longest ago" — neither collection is
|
|
87
|
+
* touched on a read, only on insert, so eviction order depends purely on
|
|
88
|
+
* insertion time. See `finishedTaskIds` and `pendingCancelled`'s own doc
|
|
89
|
+
* comments below for why a cap this size can't remove an entry either
|
|
90
|
+
* invariant still needs.
|
|
91
|
+
*/
|
|
92
|
+
export declare const MAX_TRACKED_TASK_IDS = 2000;
|
|
93
|
+
/**
|
|
94
|
+
* M5 batch-3 (workstream 2): stable, documented reason PREFIX a `task.fail`
|
|
95
|
+
* carries when `payload.limits.maxDurationMs` (daemon-authoritative
|
|
96
|
+
* wall-clock enforcement — see `armMaxDurationTimer`) is exceeded. Only the
|
|
97
|
+
* prefix itself is the contract an embedder can match against
|
|
98
|
+
* (`reason.startsWith(...)`); everything after it is human-readable detail,
|
|
99
|
+
* not part of the stable shape.
|
|
100
|
+
*/
|
|
101
|
+
export declare const MAX_DURATION_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxDurationMs";
|
|
102
|
+
/** M5 batch-3 (workstream 2): same contract as {@link MAX_DURATION_EXCEEDED_REASON_PREFIX}, for `DaemonConfig.maxTaskOutputBytes` — see `TaskRunner.pump`'s own per-event byte counting. */
|
|
103
|
+
export declare const MAX_OUTPUT_BYTES_EXCEEDED_REASON_PREFIX = "resource limit exceeded: maxTaskOutputBytes";
|
|
104
|
+
/**
|
|
105
|
+
* M5 batch-3 (workstream 2): default cap (64 MiB) on accumulated
|
|
106
|
+
* (approximate) agent-event output bytes this daemon tolerates for a single
|
|
107
|
+
* task before tearing it down as a resource-limit violation — see
|
|
108
|
+
* `TaskRunnerDeps.maxTaskOutputBytes` and `DaemonConfig.maxTaskOutputBytes`
|
|
109
|
+
* (`create-daemon.ts`) for the full contract, including the
|
|
110
|
+
* zero/negative-is-a-config-error / `Number.POSITIVE_INFINITY`-is-the-real-
|
|
111
|
+
* opt-out pin.
|
|
112
|
+
*/
|
|
113
|
+
export declare const DEFAULT_MAX_TASK_OUTPUT_BYTES: number;
|
|
114
|
+
export interface TaskRunnerDeps {
|
|
115
|
+
adapters: RuntimeAdapter[];
|
|
116
|
+
runtimeAllowlist?: string[];
|
|
117
|
+
/**
|
|
118
|
+
* M5 batch-3 (workstream 1): auto-select priority order for `pickAdapter`'s
|
|
119
|
+
* no-explicit-runtime branch — see `DaemonConfig.runtimePreference`'s own
|
|
120
|
+
* doc comment (`create-daemon.ts`) for the full rationale behind this
|
|
121
|
+
* existing at all. Unset defaults to {@link DEFAULT_RUNTIME_PREFERENCE}
|
|
122
|
+
* (pi LAST, deliberately — product decision: pi is this SDK's fallback
|
|
123
|
+
* runtime, not its default). Independent of `runtimeAllowlist` above
|
|
124
|
+
* (which restricts WHICH runtimes are eligible at all) — this only orders
|
|
125
|
+
* the attempt sequence among whatever that allowlist, if set, already let
|
|
126
|
+
* through.
|
|
127
|
+
*/
|
|
128
|
+
runtimePreference?: RuntimeId[];
|
|
129
|
+
/** M5: see `DaemonConfig.runtimeEnvironment`'s own doc comment (`create-daemon.ts`) — the per-device, per-runtime env-allowlist override `handleOffer` merges into `buildRuntimeEnv`'s `locallyAllowedNames`. */
|
|
130
|
+
runtimeEnvironment?: Record<string, {
|
|
131
|
+
allow?: string[];
|
|
132
|
+
}>;
|
|
133
|
+
permissionDefaults?: PermissionPolicy;
|
|
134
|
+
workspaceRoot: string;
|
|
135
|
+
deviceId: string;
|
|
136
|
+
send: (envelope: Envelope) => void;
|
|
137
|
+
blobClient: BlobResolver;
|
|
138
|
+
batcherOptions?: ProgressBatcherOptions;
|
|
139
|
+
/**
|
|
140
|
+
* Finding #3 (session/workspace continuity): persists `sessionRef ->
|
|
141
|
+
* workspaceDir` across daemon restarts so a `task.offer` naming a
|
|
142
|
+
* previously-reported `sessionRef` reuses that exact workspace instead of
|
|
143
|
+
* a fresh `workspaceRoot/<taskId>` — see `handleOffer` and
|
|
144
|
+
* `SessionWorkspaceStore`'s own doc comment.
|
|
145
|
+
*/
|
|
146
|
+
sessionWorkspaces: SessionWorkspaceStore;
|
|
147
|
+
gitWorkspaceManager?: GitWorkspaceManager;
|
|
148
|
+
gitWorkspaceStore?: GitWorkspaceStore;
|
|
149
|
+
onGitWorkspaceEvent?: (event: {
|
|
150
|
+
taskId: string;
|
|
151
|
+
workspaceId: string;
|
|
152
|
+
phase: GitWorkspacePhase;
|
|
153
|
+
observation?: GitWorkspaceObservation;
|
|
154
|
+
errorCategory?: string;
|
|
155
|
+
}) => void;
|
|
156
|
+
/**
|
|
157
|
+
* M4 Phase 3: this daemon's control-socket identity + the shared registry
|
|
158
|
+
* backing the control socket's own `approvals.list`/`approvals.resolve`
|
|
159
|
+
* methods (`create-daemon.ts` constructs ONE `ApprovalRegistry` and passes
|
|
160
|
+
* the SAME instance here) — see `requestApproval`'s own doc comment for
|
|
161
|
+
* why `TaskRunner` needs a handle on all three. `storeDir`/`productId` are
|
|
162
|
+
* copied verbatim into every task's `TaskContext.approvalChannel`.
|
|
163
|
+
*/
|
|
164
|
+
approvalRegistry: ApprovalRegistry;
|
|
165
|
+
storeDir: string;
|
|
166
|
+
productId: string;
|
|
167
|
+
/** Default `requestApproval` timeout — see {@link DEFAULT_APPROVAL_TIMEOUT_MS}. */
|
|
168
|
+
approvalTimeoutMs?: number;
|
|
169
|
+
/**
|
|
170
|
+
* M4 Phase 3 hardening: called by `handleApprove`/`handleReject` instead of
|
|
171
|
+
* failing the task when the referenced approval turns out to be stale
|
|
172
|
+
* (see {@link NoPendingApprovalError}) — an audit-only signal, never
|
|
173
|
+
* gating any task-state decision. `create-daemon.ts` wires this to
|
|
174
|
+
* `DaemonObserver.noteStaleApprovalDecision`, the same way every other
|
|
175
|
+
* locally-observable daemon event reaches the audit log/`tasks --follow`.
|
|
176
|
+
* Optional so a caller that doesn't care about this audit trail (e.g. a
|
|
177
|
+
* minimal test harness) isn't forced to supply one.
|
|
178
|
+
*/
|
|
179
|
+
onStaleApprovalDecision?: (taskId: string, decision: ApprovalDecision, reason?: string) => void;
|
|
180
|
+
/**
|
|
181
|
+
* Finding F4 (cross-model adversarial review): operators had no way to
|
|
182
|
+
* ever learn a pending approval's `approvalId` short of reading raw
|
|
183
|
+
* audit-log JSON — `approve`/`reject` require one, but nothing surfaced
|
|
184
|
+
* it. Called synchronously from `dispatchApproval`, BEFORE `deps.send`'s
|
|
185
|
+
* own `task.await_approval` — `create-daemon.ts` wires this to
|
|
186
|
+
* `DaemonObserver.noteApprovalDispatched`, which stashes `approvalId`
|
|
187
|
+
* keyed by `taskId` so the observer's `task.await_approval` handling
|
|
188
|
+
* (triggered by that very `deps.send` call, synchronously, right after
|
|
189
|
+
* this) can attach it to the `awaiting-approval` `DaemonEvent` it emits
|
|
190
|
+
* (see `observer.ts`'s own doc comment). Optional so a minimal test
|
|
191
|
+
* harness that doesn't care about this audit-trail detail isn't forced
|
|
192
|
+
* to supply one — mirrors `onStaleApprovalDecision`'s own contract.
|
|
193
|
+
*/
|
|
194
|
+
onApprovalDispatched?: (taskId: string, approvalId: string) => void;
|
|
195
|
+
/** Finding F5(a): overrides {@link DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS} — see `teardownActiveTask`'s own doc comment. Also the grace window `teardownActiveTask`'s hard-kill escalation (`session.close()`) gets once `session.interrupt()` itself fails to settle in time — see that method's own doc comment for why the same window is reused for both steps. */
|
|
196
|
+
shutdownInterruptTimeoutMs?: number;
|
|
197
|
+
/**
|
|
198
|
+
* M5 batch-3 (workstream 2): overrides {@link DEFAULT_MAX_TASK_OUTPUT_BYTES}
|
|
199
|
+
* — see that constant's own doc comment and `DaemonConfig.maxTaskOutputBytes`
|
|
200
|
+
* (`create-daemon.ts`) for the full contract. Validated (rejecting
|
|
201
|
+
* zero/negative) at the `DaemonConfig` layer, not here — this seam trusts
|
|
202
|
+
* its caller, same as every other optional numeric override on this
|
|
203
|
+
* interface (`shutdownInterruptTimeoutMs`, `approvalTimeoutMs`).
|
|
204
|
+
*/
|
|
205
|
+
maxTaskOutputBytes?: number;
|
|
206
|
+
/**
|
|
207
|
+
* M4 (additive-minor, `task.approval_resolved`): the negotiated
|
|
208
|
+
* `conn.ack.capabilities` of the CURRENTLY (or most recently) connected
|
|
209
|
+
* server — read fresh at call time (mirrors `getCursor`/`getToken`'s own
|
|
210
|
+
* "read fresh, not captured once" convention elsewhere in this codebase),
|
|
211
|
+
* since the capability is learned asynchronously, after this `TaskRunner`
|
|
212
|
+
* is already constructed (`create-daemon.ts`'s `start()` builds `deps`
|
|
213
|
+
* before `connection` exists). `create-daemon.ts` wires this to
|
|
214
|
+
* `ConnectionManager.getServerCapabilities`. Optional, and treated as "no
|
|
215
|
+
* capabilities" when absent, so a minimal test harness that doesn't care
|
|
216
|
+
* about this gate isn't forced to supply one — see `sendApprovalResolved`.
|
|
217
|
+
*/
|
|
218
|
+
getServerCapabilities?: () => readonly string[];
|
|
219
|
+
/**
|
|
220
|
+
* S3b (L-002): a pre-claim veto on new offers, consulted once per offer
|
|
221
|
+
* immediately after the redelivery-dedup check and ahead of every other
|
|
222
|
+
* admission check in `handleOffer`.
|
|
223
|
+
*
|
|
224
|
+
* It exists for local storage pressure (architecture §12.7.2.1's hard
|
|
225
|
+
* watermark: "停止接收新的普通 task;仍允许 terminal/truth flush、删除、导出、
|
|
226
|
+
* doctor 与恢复操作"). Placing it here rather than deeper in `handleOffer`
|
|
227
|
+
* is what makes that split real: an offer never reaches adapter selection,
|
|
228
|
+
* workspace creation, or `task.claim`, so declining costs nothing on disk —
|
|
229
|
+
* while every path that FINISHES existing work runs through code this seam
|
|
230
|
+
* is not on, and keeps working.
|
|
231
|
+
*
|
|
232
|
+
* Synchronous, matching every other single-purpose callback on this
|
|
233
|
+
* interface. A decline is `retryable` by the guard's own decision — pressure
|
|
234
|
+
* is a property of THIS device at THIS moment, so a dispatcher re-routing
|
|
235
|
+
* the task elsewhere genuinely helps; a guard declining for a reason that
|
|
236
|
+
* will not change says so.
|
|
237
|
+
*
|
|
238
|
+
* Optional and absent by default: with no guard supplied, `handleOffer`
|
|
239
|
+
* behaves exactly as it did before this seam existed.
|
|
240
|
+
*/
|
|
241
|
+
admissionGuard?: (offer: {
|
|
242
|
+
readonly taskId: string;
|
|
243
|
+
readonly payload: TaskOfferPayload;
|
|
244
|
+
}) => AdmissionGuardDecision;
|
|
245
|
+
}
|
|
246
|
+
/** See {@link TaskRunnerDeps.admissionGuard}. */
|
|
247
|
+
export type AdmissionGuardDecision = {
|
|
248
|
+
readonly admit: true;
|
|
249
|
+
} | {
|
|
250
|
+
readonly admit: false;
|
|
251
|
+
readonly reason: string;
|
|
252
|
+
readonly retryable: boolean;
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Per-connection task orchestration: offer -> (decline | claim -> adapter
|
|
256
|
+
* session -> started) -> seq-ordered progress batches -> complete/fail/
|
|
257
|
+
* cancelled, plus approve/reject/cancel/steer handling.
|
|
258
|
+
*
|
|
259
|
+
* M1 rework (docs/protocol.md §3, §5, §10 — `packages/protocol` is frozen,
|
|
260
|
+
* not editable here): pre-claim rejections (unknown/disallowed runtime,
|
|
261
|
+
* policy exceeding this device's ceiling) now send `task.decline` and never
|
|
262
|
+
* claim at all — `TASK_TRANSITIONS.Offered` gained a direct `-> Failed` edge
|
|
263
|
+
* precisely so this no longer has to claim-then-fail. A successful claim is
|
|
264
|
+
* followed by `task.started` only once the adapter session has actually
|
|
265
|
+
* started (`task.claim` alone no longer implies `Running`). Cancellation
|
|
266
|
+
* reports the explicit `task.cancelled` message instead of the old
|
|
267
|
+
* `task.fail({reason:'cancelled'})` convention.
|
|
268
|
+
*/
|
|
269
|
+
export declare class TaskRunner {
|
|
270
|
+
private readonly deps;
|
|
271
|
+
private readonly tasks;
|
|
272
|
+
/**
|
|
273
|
+
* Finding F4 (cancel lost during the offer-processing window): a
|
|
274
|
+
* `task.cancel` for a taskId that hasn't finished `handleOffer` yet (still
|
|
275
|
+
* awaiting adapter detection / instruction resolution / workspace setup /
|
|
276
|
+
* `adapter.start()`) has no `this.tasks` entry to land on — it used to be
|
|
277
|
+
* silently dropped, and the runtime session `handleOffer` was about to
|
|
278
|
+
* register would then run an unsupervised ("zombie") turn nobody asked
|
|
279
|
+
* for anymore. Recording the taskId here lets `handleOffer` consult it at
|
|
280
|
+
* the two points where it can still safely react (see its body): before
|
|
281
|
+
* claiming at all (decline instead of ever starting a session), and right
|
|
282
|
+
* after `adapter.start()` resolves but before this task is registered as
|
|
283
|
+
* active (tear the just-started session down immediately, before its
|
|
284
|
+
* event loop ever pumps a single event). Consumed (deleted) at whichever
|
|
285
|
+
* checkpoint handles it; a cancel for a taskId that's already active,
|
|
286
|
+
* already finished, or never offered at all leaves a harmless entry that
|
|
287
|
+
* nothing will ever consult.
|
|
288
|
+
*
|
|
289
|
+
* M3-B: that last sentence is exactly the unbounded-growth vector this
|
|
290
|
+
* needed closed for long-lived operation — a cancel for a taskId nobody
|
|
291
|
+
* ever claims (unknown, already active, or already finished) leaves a
|
|
292
|
+
* permanent entry with nothing left to consume it. Bounded to
|
|
293
|
+
* `MAX_TRACKED_TASK_IDS` via `setPendingCancelled` below, oldest evicted
|
|
294
|
+
* first: safe because every entry this field's correctness actually
|
|
295
|
+
* depends on is consumed (deleted) by one of `handleOffer`'s two
|
|
296
|
+
* checkpoints within that SAME task's own offer-processing window — one
|
|
297
|
+
* in-flight task's startup latency, nowhere near enough churn for eviction
|
|
298
|
+
* to remove an entry still inside its consuming window before it's read.
|
|
299
|
+
*/
|
|
300
|
+
private readonly pendingCancelled;
|
|
301
|
+
/**
|
|
302
|
+
* Finding #5 (Codex counterexample): taskIds currently INSIDE `handleOffer`
|
|
303
|
+
* — from the moment it decides an offer is worth processing until it
|
|
304
|
+
* reaches one of its own resolution points (decline, fail, the
|
|
305
|
+
* checkpoint-2 cancel-teardown, or successful registration into
|
|
306
|
+
* `this.tasks`). Bounded eviction on `pendingCancelled` (below) must never
|
|
307
|
+
* remove an entry for a taskId in this set: doing so is exactly the bug —
|
|
308
|
+
* block task A in `adapter.start()`, deliver A's own `task.cancel` (so
|
|
309
|
+
* `pendingCancelled` gets an entry for A while A is still in-flight),
|
|
310
|
+
* then deliver `MAX_TRACKED_TASK_IDS` more cancels for unrelated taskIds
|
|
311
|
+
* nobody ever offered — under naive oldest-wins eviction, A's entry (the
|
|
312
|
+
* single oldest) gets evicted purely because of unrelated churn, so when
|
|
313
|
+
* `adapter.start()` finally resolves, checkpoint 2 finds no cancel marker
|
|
314
|
+
* and the already-cancelled task starts a real session. See
|
|
315
|
+
* `evictPendingCancelled` below for the fix, and
|
|
316
|
+
* `task-runner-bounded-collections.test.ts` for a test mirroring this
|
|
317
|
+
* exact scenario. Membership here is naturally tiny (bounded by this
|
|
318
|
+
* device's real concurrent-offer-processing count, nowhere near
|
|
319
|
+
* `MAX_TRACKED_TASK_IDS`), so scanning past it to find an evictable entry
|
|
320
|
+
* costs nothing.
|
|
321
|
+
*/
|
|
322
|
+
private readonly inFlightOffers;
|
|
323
|
+
/**
|
|
324
|
+
* Finding P2 (Fix 2c): taskIds that have reached a terminal outcome
|
|
325
|
+
* (Complete/Failed/Cancelled) this session — populated in `finish()`.
|
|
326
|
+
* While `ConnectionManager`'s stalled-cursor long-poll re-pull is frozen
|
|
327
|
+
* behind an unrelated failing seq, it can legitimately redeliver an
|
|
328
|
+
* ALREADY-succeeded `task.offer` — the client's own cursor hasn't advanced
|
|
329
|
+
* past it yet (docs/protocol.md §9's "cursor advance timing" rule
|
|
330
|
+
* explicitly relies on redelivered handlers being idempotent for exactly
|
|
331
|
+
* this reason). `handleOffer` must treat a redelivered offer for a taskId
|
|
332
|
+
* that's already active (`this.tasks`) or already finished (this set) as
|
|
333
|
+
* a no-op — never a second `adapter.start()` call, which would orphan the
|
|
334
|
+
* first session.
|
|
335
|
+
*
|
|
336
|
+
* M3-B: unbounded otherwise — a long-lived daemon that's finished many
|
|
337
|
+
* thousands of tasks over its uptime would keep every single taskId
|
|
338
|
+
* forever. Bounded to `MAX_TRACKED_TASK_IDS` via `addFinishedTaskId`
|
|
339
|
+
* below, oldest evicted first. Safe for the redelivery-idempotency
|
|
340
|
+
* invariant above because the stalled-cursor scenario above redelivers
|
|
341
|
+
* this device's own recent backlog for one connection, not an arbitrary
|
|
342
|
+
* point in this daemon's whole history — this device would have to claim
|
|
343
|
+
* and finish `MAX_TRACKED_TASK_IDS` more tasks before a genuinely-still-
|
|
344
|
+
* pending redelivery for an older taskId even arrives, let alone gets
|
|
345
|
+
* processed, for eviction to ever remove an entry that redelivery still
|
|
346
|
+
* needed.
|
|
347
|
+
*
|
|
348
|
+
* Finding #5 (honesty follow-up): unlike `pendingCancelled`, plain
|
|
349
|
+
* oldest-first eviction IS correct here — every entry in this set is
|
|
350
|
+
* already fully resolved (finish() only adds a taskId after it reached a
|
|
351
|
+
* terminal outcome), so there is no "in-flight" entry an eviction could
|
|
352
|
+
* corrupt out from under a running `handleOffer()`. The assumption above
|
|
353
|
+
* is a HEURISTIC bound, not a proof: it holds as long as no single
|
|
354
|
+
* connection's genuinely-still-pending redelivery backlog ever exceeds
|
|
355
|
+
* `MAX_TRACKED_TASK_IDS` finished tasks, which is a real (if distant)
|
|
356
|
+
* possibility for an extremely long-stalled connection, not a
|
|
357
|
+
* mathematical impossibility. Should it ever be violated, the failure
|
|
358
|
+
* mode is strictly milder than `pendingCancelled`'s own pre-fix bug: a
|
|
359
|
+
* redelivered `task.offer` for an evicted, already-finished taskId would
|
|
360
|
+
* re-run `handleOffer` from scratch — at worst a duplicate
|
|
361
|
+
* claim/start/complete for a task that already succeeded once — never a
|
|
362
|
+
* task that should be dead starting a brand-new session against explicit
|
|
363
|
+
* cancellation intent.
|
|
364
|
+
*/
|
|
365
|
+
private readonly finishedTaskIds;
|
|
366
|
+
/**
|
|
367
|
+
* M4 Phase 2 (daemon control socket `shutdown` RPC): set once by
|
|
368
|
+
* {@link stopAcceptingOffers}, checked at the very top of `handleOffer` —
|
|
369
|
+
* see that method's own doc comment for why offers must stop being
|
|
370
|
+
* claimed BEFORE currently-active tasks are reported failed in
|
|
371
|
+
* {@link shutdownActiveTasks}, not after. Irreversible for this
|
|
372
|
+
* `TaskRunner` instance; a fresh one is constructed on the daemon's next
|
|
373
|
+
* `start()`.
|
|
374
|
+
*/
|
|
375
|
+
private stoppingOffers;
|
|
376
|
+
constructor(deps: TaskRunnerDeps);
|
|
377
|
+
get activeTaskCount(): number;
|
|
378
|
+
/** M5 batch-3 (workstream 2): effective `maxTaskOutputBytes` cap for this daemon — see {@link DEFAULT_MAX_TASK_OUTPUT_BYTES}'s own doc comment. */
|
|
379
|
+
private get maxTaskOutputBytes();
|
|
380
|
+
/**
|
|
381
|
+
* M4 Phase 4 (part B.3, observability): per-active-task queue watermarks
|
|
382
|
+
* for the control socket's `status` result — see
|
|
383
|
+
* `control-protocol.ts`'s `TaskQueueWatermark` doc comment for why this
|
|
384
|
+
* reflects the daemon's own progress-batcher backlog and in-flight
|
|
385
|
+
* approval count, not the adapter's own event-queue depth.
|
|
386
|
+
*/
|
|
387
|
+
getQueueWatermarks(): TaskQueueWatermark[];
|
|
388
|
+
/** M4 Phase 2: stop claiming any FUTURE `task.offer` — see `stoppingOffers`'s own doc comment. Idempotent. */
|
|
389
|
+
stopAcceptingOffers(): void;
|
|
390
|
+
/**
|
|
391
|
+
* M4 Phase 2: best-effort shutdown of every currently ACTIVE task, for the
|
|
392
|
+
* control socket's `shutdown` RPC. Mirrors `handleCancel`'s best-effort
|
|
393
|
+
* `session.interrupt()` style (an interrupt failure is swallowed; the
|
|
394
|
+
* terminal message is sent either way) but reports `task.fail` rather than
|
|
395
|
+
* `task.cancelled` — these tasks aren't ending because the SERVER
|
|
396
|
+
* cancelled them, they're ending because this device is shutting down.
|
|
397
|
+
* `retryable: true` throughout: nothing about the task/policy itself was
|
|
398
|
+
* ever at fault, only this device's own availability right now.
|
|
399
|
+
*
|
|
400
|
+
* Snapshots `this.tasks` into a plain array up front rather than iterating
|
|
401
|
+
* the live `Map` — `finish()` (called per task below) deletes from that
|
|
402
|
+
* same map as each shutdown settles, and a snapshot avoids relying on
|
|
403
|
+
* "mutate while iterating" semantics being followed correctly here.
|
|
404
|
+
*
|
|
405
|
+
* Must be called AFTER {@link stopAcceptingOffers} and BEFORE the
|
|
406
|
+
* connection is closed: the caller (`create-daemon.ts`'s
|
|
407
|
+
* `performControlShutdown`) awaits this method to fully settle — every
|
|
408
|
+
* `task.fail` actually enqueued via `deps.send` — before it ever calls
|
|
409
|
+
* `stop()` (which closes the connection). Stopping offers first (rather
|
|
410
|
+
* than closing the connection first) is what prevents a new
|
|
411
|
+
* `task.offer` from being claimed in the window while these are being
|
|
412
|
+
* torn down.
|
|
413
|
+
*
|
|
414
|
+
* This ordering invariant is NOT just about `performControlShutdown`'s
|
|
415
|
+
* own internal statement order — it also depends on nothing ELSE
|
|
416
|
+
* closing the connection first. A real regression (gatekeeper-caught,
|
|
417
|
+
* fixed in `create-daemon.ts`/`bin/commands/start.ts`) had exactly that
|
|
418
|
+
* happen: `start.ts` used to wake up on the EARLIER `shutdown-requested`
|
|
419
|
+
* event (fired synchronously, before this method even calls
|
|
420
|
+
* `session.interrupt()`) and call `daemon.stop()` itself, racing ahead
|
|
421
|
+
* and closing the connection before this method's `task.fail` send ever
|
|
422
|
+
* reached the outbox drain. `start.ts` now waits for the LATER
|
|
423
|
+
* `shutdown-complete` event (emitted only after `performControlShutdown`'s
|
|
424
|
+
* own `stop()` call has already resolved), so it can no longer race
|
|
425
|
+
* ahead of this method — see `daemon-control-socket.test.ts`'s dedicated
|
|
426
|
+
* regression test for the exact scenario.
|
|
427
|
+
*/
|
|
428
|
+
shutdownActiveTasks(reason: string): Promise<void>;
|
|
429
|
+
/**
|
|
430
|
+
* M5 batch-3 (workstream 2): the ONE shared per-task teardown sequence —
|
|
431
|
+
* "reuse the exact interrupt/teardown machinery `shutdownActiveTasks`
|
|
432
|
+
* uses, do not invent a second teardown path" applies to BOTH callers:
|
|
433
|
+
* graceful daemon shutdown ({@link shutdownTask}, `retryable: true`) and
|
|
434
|
+
* resource-limit enforcement ({@link failActiveTaskForResourceLimit},
|
|
435
|
+
* `retryable: false`, wall-clock `maxDurationMs` / output-cap
|
|
436
|
+
* `maxTaskOutputBytes`).
|
|
437
|
+
*
|
|
438
|
+
* Finding F5(a) (pre-existing, unchanged by this refactor):
|
|
439
|
+
* `session.interrupt()` is raced against `timeoutMs`
|
|
440
|
+
* ({@link DEFAULT_SHUTDOWN_INTERRUPT_TIMEOUT_MS}, overridable via
|
|
441
|
+
* `TaskRunnerDeps.shutdownInterruptTimeoutMs`) rather than awaited
|
|
442
|
+
* unconditionally, so a hung `interrupt()` (a misbehaving adapter) can
|
|
443
|
+
* never block `task.fail` from being sent at all.
|
|
444
|
+
*
|
|
445
|
+
* New in this batch — hard-kill escalation: when `interrupt()` does NOT
|
|
446
|
+
* settle within that same grace window, `session.close()` is tried next
|
|
447
|
+
* (ALSO raced against `timeoutMs`, for the identical reason: a hung
|
|
448
|
+
* `close()` must not be able to block this forever either — which matters
|
|
449
|
+
* far more here than it used to for the pre-existing graceful-shutdown-only
|
|
450
|
+
* caller, since THAT path is additionally bounded by an outer deadline
|
|
451
|
+
* (`SHUTDOWN_TASK_TEARDOWN_DEADLINE_MS`/`DaemonConfig.shutdownGraceMs`,
|
|
452
|
+
* `create-daemon.ts`), while resource-limit enforcement fires during
|
|
453
|
+
* ordinary operation with no such outer bound watching it). `close()` is
|
|
454
|
+
* every adapter's harder teardown primitive — an actual process-level kill
|
|
455
|
+
* (SIGTERM, or `taskkill /F` on Windows — see e.g.
|
|
456
|
+
* `ClaudeProcessClient.kill()`/`PiRpcClient.kill()`) as opposed to pi's own
|
|
457
|
+
* soft in-band `interrupt()` (an RPC `abort` message that leaves the
|
|
458
|
+
* process alive and resumable) — so escalating to it is the closest thing
|
|
459
|
+
* to a "hard kill" the `Session` interface exposes. `finish()` below calls
|
|
460
|
+
* `session.close()` again regardless (documented idempotent) — this isn't
|
|
461
|
+
* a substitute for that, only an earlier, bounded attempt at actually
|
|
462
|
+
* stopping a stuck runtime before this method gives up and reports failure
|
|
463
|
+
* anyway.
|
|
464
|
+
*
|
|
465
|
+
* Re-checks task identity (`this.tasks.get(...) === active`) immediately
|
|
466
|
+
* before sending `task.fail`: the interrupt/hard-kill race above has await
|
|
467
|
+
* points during which a DIFFERENT path (a racing `task.cancel`/
|
|
468
|
+
* `task.reject`, or the session completing normally on its own) may have
|
|
469
|
+
* already finished this exact task and sent its own terminal message.
|
|
470
|
+
* Sending a SECOND terminal message for an already-finished task would be
|
|
471
|
+
* a genuine protocol bug, not a benign race — mirrors `pump()`'s own
|
|
472
|
+
* identity-check guard for the same class of race.
|
|
473
|
+
*/
|
|
474
|
+
private teardownActiveTask;
|
|
475
|
+
/** Graceful-shutdown caller of {@link teardownActiveTask} — see `shutdownActiveTasks`'s own doc comment. `retryable: true`: nothing about the task/policy itself was ever at fault, only this device's own availability right now. */
|
|
476
|
+
private shutdownTask;
|
|
477
|
+
/**
|
|
478
|
+
* M5 batch-3 (workstream 2): shared entry point for both resource-limit
|
|
479
|
+
* enforcers (wall-clock `maxDurationMs` — {@link armMaxDurationTimer} —
|
|
480
|
+
* and output-cap `maxTaskOutputBytes` — see `pump`). Looks the task up
|
|
481
|
+
* FRESH by id and no-ops if it's already gone — finished via any other
|
|
482
|
+
* path (normal completion, cancel, reject, daemon shutdown, or a
|
|
483
|
+
* DIFFERENT resource-limit trip already caught it first). `retryable:
|
|
484
|
+
* false` unconditionally: hitting a configured resource ceiling is never a
|
|
485
|
+
* transient/environmental failure a retry could fix — the same task under
|
|
486
|
+
* the same limits would just hit it again.
|
|
487
|
+
*/
|
|
488
|
+
private failActiveTaskForResourceLimit;
|
|
489
|
+
/**
|
|
490
|
+
* M5 batch-3 (workstream 2): daemon-authoritative wall-clock enforcement
|
|
491
|
+
* for `payload.limits.maxDurationMs` — previously accepted and silently
|
|
492
|
+
* ignored (see `handleOffer`'s own doc comment on the `limits.maxTokens`
|
|
493
|
+
* gate for the historical context this superseded). Armed once, at the
|
|
494
|
+
* moment this task is registered as active (`handleOffer`, still inside
|
|
495
|
+
* the synchronous construct -> register -> arm -> pump handoff — arming a
|
|
496
|
+
* timer is synchronous, `setTimeout` never invokes its callback in the
|
|
497
|
+
* same tick, so this doesn't reopen the race that handoff's own doc
|
|
498
|
+
* comment guards against). Cleared unconditionally in `finish()` so every
|
|
499
|
+
* terminal outcome leaves no dangling timer and can never double-fail an
|
|
500
|
+
* already-finished task — the fresh `this.tasks.get` lookup in
|
|
501
|
+
* `failActiveTaskForResourceLimit`/`teardownActiveTask`'s own identity
|
|
502
|
+
* re-check is the second, belt-and-suspenders layer of that same guarantee
|
|
503
|
+
* for the rare case the timer's callback was already scheduled before
|
|
504
|
+
* `finish()` had a chance to clear it.
|
|
505
|
+
*/
|
|
506
|
+
private armMaxDurationTimer;
|
|
507
|
+
handleEnvelope(envelope: Envelope): Promise<void>;
|
|
508
|
+
private handleOffer;
|
|
509
|
+
/** Protocol §7: an instruction too large to inline arrives as a `blobRef` — resolve it via the blob client rather than failing closed. */
|
|
510
|
+
private resolveInstruction;
|
|
511
|
+
private pump;
|
|
512
|
+
/**
|
|
513
|
+
* Protocol §7: an `artifact` `AgentEvent` only names a file the runtime
|
|
514
|
+
* wrote into the task workspace (`name`/`contentType` — it carries no
|
|
515
|
+
* content of its own); this reads it from disk and sends the actual
|
|
516
|
+
* `task.artifact` wire message — inline (base64) under 64KB, or via blob
|
|
517
|
+
* upload above that, with a sha-256 `contentHash`.
|
|
518
|
+
*
|
|
519
|
+
* Finding F7/N5: `name` is untrusted (it's whatever the runtime/agent
|
|
520
|
+
* reported — ultimately model-influenced) and used to be `path.join`'d
|
|
521
|
+
* onto `workspaceDir` with no check that the result stayed inside it, so
|
|
522
|
+
* `../../<anything>` (or an absolute `name`, which `path.resolve` accepts
|
|
523
|
+
* verbatim as the whole path) could read and exfiltrate an arbitrary file
|
|
524
|
+
* on the host as a task artifact. A later fix (`resolveArtifactPath`)
|
|
525
|
+
* closed the traversal case by realpath-checking containment, but still
|
|
526
|
+
* returned a path string that was reopened by pathname afterward — a
|
|
527
|
+
* check-then-use TOCTOU race letting the final component be swapped for
|
|
528
|
+
* an out-of-workspace symlink between the check and the read.
|
|
529
|
+
* `openArtifact` now opens the file (with `O_NOFOLLOW`) and verifies the
|
|
530
|
+
* resulting file descriptor directly; this reads from that same handle,
|
|
531
|
+
* never re-opening by pathname. Read/upload failures (including a
|
|
532
|
+
* rejected name or a blocked symlink swap) are also not silent: they
|
|
533
|
+
* surface as a loud `error` `AgentEvent` batched into `task.progress`,
|
|
534
|
+
* and are logged — the task itself can still reach `task.complete`
|
|
535
|
+
* normally, but the dropped artifact is now visible in the event stream
|
|
536
|
+
* rather than swallowed.
|
|
537
|
+
*/
|
|
538
|
+
private sendArtifact;
|
|
539
|
+
/** Loud, non-silent artifact failure (finding F7): logged, and folded into this task's own progress stream as an `error` AgentEvent rather than swallowed — the task itself can still complete normally, but the omission is now visible. */
|
|
540
|
+
private reportArtifactError;
|
|
541
|
+
private handleCancel;
|
|
542
|
+
/** M3-B: bounded insert for `pendingCancelled` — see its class-level doc comment and `MAX_TRACKED_TASK_IDS`. Evicts the oldest SAFE-TO-EVICT entry once over cap — see `evictPendingCancelled` (finding #5: not simply "the oldest entry", which could be an in-flight offer's own cancel marker). */
|
|
543
|
+
private setPendingCancelled;
|
|
544
|
+
/**
|
|
545
|
+
* Finding #5 (Codex counterexample — see `inFlightOffers`'s class-level
|
|
546
|
+
* doc comment for the exact scenario): evicts the OLDEST entry that is
|
|
547
|
+
* NOT a taskId currently inside `handleOffer`'s in-flight window, rather
|
|
548
|
+
* than unconditionally the single oldest entry. `Map` iterates in
|
|
549
|
+
* insertion order, so this is "oldest entry that's safe to drop," which
|
|
550
|
+
* only differs from "the oldest entry, period" when that oldest entry
|
|
551
|
+
* happens to belong to a task still being processed — exactly the case
|
|
552
|
+
* that must never be evicted, since `handleOffer`'s own checkpoint 2
|
|
553
|
+
* still needs to observe it.
|
|
554
|
+
*
|
|
555
|
+
* `inFlightOffers` is naturally tiny (bounded by this device's real
|
|
556
|
+
* concurrent-offer-processing count — normally single digits, driven by
|
|
557
|
+
* how many `task.offer`s are simultaneously mid-`adapter.start()` — nowhere
|
|
558
|
+
* near `MAX_TRACKED_TASK_IDS`), so this scan is cheap in practice: it
|
|
559
|
+
* finds a safe entry at or near the front almost always. The only case
|
|
560
|
+
* where NO entry is safe to evict is every single tracked cancel
|
|
561
|
+
* belonging to a currently in-flight offer, which would require this
|
|
562
|
+
* device to have `MAX_TRACKED_TASK_IDS` offers mid-processing
|
|
563
|
+
* simultaneously — implausible, but handled without corrupting anything:
|
|
564
|
+
* this insert is simply allowed to leave the map one entry over cap
|
|
565
|
+
* rather than evict something still needed, and it shrinks back under cap
|
|
566
|
+
* as those in-flight offers resolve and their entries get CONSUMED
|
|
567
|
+
* (deleted by `handleOffer` itself) rather than evicted.
|
|
568
|
+
*/
|
|
569
|
+
private evictPendingCancelled;
|
|
570
|
+
/**
|
|
571
|
+
* S0/H-006: an inbound `task.steer` is normally impossible for a runtime
|
|
572
|
+
* that cannot steer — the hub gates it at claim-time capability
|
|
573
|
+
* (`steer_unsupported_runtime`) and never sends the envelope. If one
|
|
574
|
+
* arrives anyway (a forged sender, a pre-gate server, a device whose
|
|
575
|
+
* adapter set changed), the session throws {@link SteerUnsupportedError},
|
|
576
|
+
* which is a PERMANENT property of that runtime, not a transient failure.
|
|
577
|
+
*
|
|
578
|
+
* Rethrowing it would hand it to `ConnectionManager.process()`
|
|
579
|
+
* (`connection-manager.ts` `stalledAtSeq`), which freezes the cursor at
|
|
580
|
+
* that seq and redelivers the same envelope forever — every retry
|
|
581
|
+
* guaranteed to fail identically, and every later envelope for every
|
|
582
|
+
* other task blocked behind it. So this is classified as a
|
|
583
|
+
* non-retryable protocol/authority error: record it and return normally,
|
|
584
|
+
* which acks the envelope and lets the cursor advance. Nothing is
|
|
585
|
+
* swallowed — the steer simply has no reachable success state, and the
|
|
586
|
+
* honest terminal action is to log it and move on.
|
|
587
|
+
*
|
|
588
|
+
* Every OTHER error stays transient and is rethrown untouched, preserving
|
|
589
|
+
* the existing stall/redelivery semantics exactly.
|
|
590
|
+
*/
|
|
591
|
+
private handleSteer;
|
|
592
|
+
/**
|
|
593
|
+
* M4 Phase 3: the daemon-side half of the out-of-band approval channel
|
|
594
|
+
* (`types.ts`'s `ApprovalChannel`) — called from `create-daemon.ts`'s
|
|
595
|
+
* `approvals.request` control method, itself called by `byok-approval-mcp`
|
|
596
|
+
* (a claude-spawned MCP-server child process, NOT the adapter/session
|
|
597
|
+
* in-process — see `ApprovalChannel`'s own doc comment for the full why
|
|
598
|
+
* this seam exists at all rather than an `AgentEvent`).
|
|
599
|
+
*
|
|
600
|
+
* Deliberately independent of the dormant `needs_approval` `AgentEvent`
|
|
601
|
+
* path in `pump()` below (~line 611): empirically confirmed (M4 Phase 3
|
|
602
|
+
* STEP 0), claude's own stream-json output emits NOTHING while a
|
|
603
|
+
* permission-prompt-tool call is outstanding — the gap between a `tool_use`
|
|
604
|
+
* frame and its `tool_result` is invisible on the wire, indistinguishable
|
|
605
|
+
* from ordinary model "thinking" latency. `pump()`'s for-await loop over
|
|
606
|
+
* `active.session.events` therefore has no event to ever branch on for
|
|
607
|
+
* this case; the ONLY signal that a task is paused arrives out-of-band,
|
|
608
|
+
* over the control socket, which is exactly what this method is for. The
|
|
609
|
+
* `needs_approval` path stays dormant, untouched, for a hypothetical
|
|
610
|
+
* future adapter whose runtime DOES expose the pause on its own event
|
|
611
|
+
* stream.
|
|
612
|
+
*
|
|
613
|
+
* Sends `task.await_approval` (protocol §5), registers a fresh entry in
|
|
614
|
+
* `deps.approvalRegistry`, and races it against `deps.approvalTimeoutMs`
|
|
615
|
+
* (default {@link DEFAULT_APPROVAL_TIMEOUT_MS}) — an unanswered request
|
|
616
|
+
* force-resolves as a fail-closed rejection once the deadline passes. Both
|
|
617
|
+
* that timeout AND a real decision (server wire `task.approve`/
|
|
618
|
+
* `task.reject` via `handleApprove`/`handleReject` below, OR the local
|
|
619
|
+
* CLI's `approvals.resolve` in `control-server.ts`) converge on the exact
|
|
620
|
+
* same `ApprovalRegistry.resolve()` call — "first resolution wins, the
|
|
621
|
+
* loser is a clean already-resolved no-op" is `ApprovalRegistry`'s own
|
|
622
|
+
* existing guarantee, reused here rather than reimplemented.
|
|
623
|
+
*
|
|
624
|
+
* Fails closed immediately (no registry entry ever created) for a `taskId`
|
|
625
|
+
* that isn't currently active on this device — a stale/unknown/
|
|
626
|
+
* already-finished task has nothing to pause.
|
|
627
|
+
*
|
|
628
|
+
* M4 Phase 4 (fold-in from the P3 gate — concurrent-approval-overwrite
|
|
629
|
+
* fix): claude's parallel tool use can call this MORE THAN ONCE for the
|
|
630
|
+
* SAME task before the first call's approval is resolved — each parallel
|
|
631
|
+
* tool call is its own independent `byok-approval-mcp` `tools/call`
|
|
632
|
+
* request, and the MCP protocol lets several be in flight on one
|
|
633
|
+
* connection at once (see `byok-approval-mcp.ts`'s own doc comment on
|
|
634
|
+
* sharing one control-socket connection across them). Before this fix,
|
|
635
|
+
* `active.pendingApprovalId = approvalId` above was unconditional — a
|
|
636
|
+
* second concurrent call for the same task silently overwrote the first
|
|
637
|
+
* call's id, so only the LATEST request was ever wire-resolvable
|
|
638
|
+
* (`ctx.approvalChannel.resolve`, below, and any server `task.approve`/
|
|
639
|
+
* `task.reject`, both resolve by looking up `active.pendingApprovalId`);
|
|
640
|
+
* every earlier one could only ever time out.
|
|
641
|
+
*
|
|
642
|
+
* Fix: only ONE approval per task is ever actually DISPATCHED (registered
|
|
643
|
+
* in `approvalRegistry` + `task.await_approval` sent + its own timeout
|
|
644
|
+
* window running) at a time — see `dispatchApproval` below. A second
|
|
645
|
+
* (third, ...) concurrent call for a task that already has one dispatched
|
|
646
|
+
* queues (FIFO, `active.approvalQueue`) instead of overwriting anything,
|
|
647
|
+
* and is only dispatched — with its OWN fresh approvalId and its OWN
|
|
648
|
+
* timeout window starting at THAT dispatch, not at this call's arrival —
|
|
649
|
+
* once the currently-dispatched one resolves (see
|
|
650
|
+
* `dispatchNextQueuedApproval`). The MCP callers on the other end are
|
|
651
|
+
* already independently blocked, each awaiting its own `requestApproval`
|
|
652
|
+
* promise, so this added latency for a queued request is transparent to
|
|
653
|
+
* them: nothing here changes what claude itself observes beyond "the
|
|
654
|
+
* answer took a bit longer." Bounded by
|
|
655
|
+
* {@link MAX_PENDING_APPROVALS_PER_TASK}: a request arriving once this
|
|
656
|
+
* task's queue is already full is rejected fail-closed immediately,
|
|
657
|
+
* mirroring the unknown/inactive-taskId case above.
|
|
658
|
+
*
|
|
659
|
+
* C1 (cross-model review, P1): `onOrigin`, if supplied, is invoked
|
|
660
|
+
* synchronously — strictly BEFORE this method's own returned promise
|
|
661
|
+
* resolves — with the `ApprovalOrigin` (`'wire' | 'local'`) the eventual
|
|
662
|
+
* decision actually resolved through (see `ApprovalRegistry.resolve`'s own
|
|
663
|
+
* `origin` parameter). Purely additive/internal: every existing caller
|
|
664
|
+
* (`byok-approval-mcp.ts`, `create-daemon.ts`'s control socket, this file's
|
|
665
|
+
* own tests) omits it and observes exactly the same `{approved, reason}`
|
|
666
|
+
* resolution as before. `pump()`'s dormant `needs_approval` branch is the
|
|
667
|
+
* one caller that supplies it, to decide whether it still needs to
|
|
668
|
+
* forward the decision into `active.session.resolveApproval()` itself —
|
|
669
|
+
* see that branch's own doc comment for why origin can't simply ride
|
|
670
|
+
* along on the resolved value instead.
|
|
671
|
+
*/
|
|
672
|
+
requestApproval(taskId: string, summary: string, onOrigin?: (origin: ApprovalOrigin) => void): Promise<{
|
|
673
|
+
approved: boolean;
|
|
674
|
+
reason?: string;
|
|
675
|
+
}>;
|
|
676
|
+
/**
|
|
677
|
+
* Actually dispatch one approval request for `active`'s task: register it
|
|
678
|
+
* in `deps.approvalRegistry`, send its `task.await_approval`, and start its
|
|
679
|
+
* own `deps.approvalTimeoutMs` window — see `requestApproval`'s own doc
|
|
680
|
+
* comment for why this is split out (only ever ONE dispatched per task at
|
|
681
|
+
* a time; everything else queues). Called either immediately
|
|
682
|
+
* (`requestApproval`, nothing else pending for this task) or from
|
|
683
|
+
* `dispatchNextQueuedApproval` once the previously-dispatched request for
|
|
684
|
+
* this same task resolves.
|
|
685
|
+
*
|
|
686
|
+
* C1: `onOrigin` — see `requestApproval`'s own doc comment — is forwarded
|
|
687
|
+
* verbatim from whichever caller dispatched this (directly, or via
|
|
688
|
+
* `QueuedApprovalRequest.onOrigin` once `dispatchNextQueuedApproval` pulls
|
|
689
|
+
* it off the queue) and invoked from the registered `onResolve` callback
|
|
690
|
+
* below, BEFORE `resolve(...)` — so it always fires strictly before this
|
|
691
|
+
* method's own returned promise settles.
|
|
692
|
+
*/
|
|
693
|
+
private dispatchApproval;
|
|
694
|
+
/**
|
|
695
|
+
* M4 (additive-minor, `task.approval_resolved` — see `messages.ts`'s own
|
|
696
|
+
* doc comment on `TaskApprovalResolvedPayloadSchema` for the full wire
|
|
697
|
+
* rationale): report a LOCALLY-resolved approval to the server
|
|
698
|
+
* immediately, gated on the negotiated `approval_resolved` capability
|
|
699
|
+
* (`deps.getServerCapabilities` — an old server that never advertises it
|
|
700
|
+
* never receives this message; the daemon then falls back to the
|
|
701
|
+
* pre-existing implicit-resume inference, unconditionally, exactly as
|
|
702
|
+
* before this message existed — the N/N-1 compatibility path).
|
|
703
|
+
*
|
|
704
|
+
* Ordering (verified by `task-runner-approval-resolved.test.ts`): this is
|
|
705
|
+
* called, and therefore `deps.send` pushes this envelope onto the outbox,
|
|
706
|
+
* SYNCHRONOUSLY from the `onResolve` callback above — strictly BEFORE the
|
|
707
|
+
* `resolve(...)` call on the very next line that unblocks whatever was
|
|
708
|
+
* awaiting `requestApproval()`'s promise (`byok-approval-mcp`, ultimately
|
|
709
|
+
* the paused runtime turn). Any further progress from the resumed session
|
|
710
|
+
* can only be produced AFTER that unblock, which needs at least one more
|
|
711
|
+
* microtask/event-loop turn — so `task.approval_resolved` is always queued
|
|
712
|
+
* ahead of it with no extra bookkeeping needed here.
|
|
713
|
+
*/
|
|
714
|
+
private sendApprovalResolved;
|
|
715
|
+
/**
|
|
716
|
+
* FIFO: once a task's currently-dispatched approval resolves (real
|
|
717
|
+
* decision or timeout), dispatch the next queued request for that SAME
|
|
718
|
+
* task, if any — see `requestApproval`'s own doc comment. A no-op when
|
|
719
|
+
* nothing is queued.
|
|
720
|
+
*/
|
|
721
|
+
private dispatchNextQueuedApproval;
|
|
722
|
+
/**
|
|
723
|
+
* Acceptance finding 1 (dormant `needs_approval` branch bypassing the
|
|
724
|
+
* approval registry): resolves whatever `deps.approvalRegistry` entry
|
|
725
|
+
* `pendingId` names (if any — a caller passes `undefined` when nothing was
|
|
726
|
+
* pending to begin with), tagged `'wire'` — the same origin
|
|
727
|
+
* `ctx.approvalChannel.resolve` already uses for a server-sent
|
|
728
|
+
* `task.approve`/`task.reject` (see `ApprovalOrigin`'s own doc comment:
|
|
729
|
+
* `'wire'` is what keeps `sendApprovalResolved` from echoing
|
|
730
|
+
* `task.approval_resolved` back to a server that already knows this
|
|
731
|
+
* decision, since it sent it).
|
|
732
|
+
*
|
|
733
|
+
* Needed because `active.session.resolveApproval()` is adapter-defined:
|
|
734
|
+
* - A channel-based session (claude) already resolves this exact registry
|
|
735
|
+
* entry itself, via `ctx.approvalChannel.resolve` (`handleOffer` above)
|
|
736
|
+
* — by the time this runs, that entry is already gone, so this call
|
|
737
|
+
* throws `ApprovalNotFoundError`, swallowed below: the same
|
|
738
|
+
* first-resolution-wins race every other caller of `.resolve()` in this
|
|
739
|
+
* file already treats as benign (see e.g. `dispatchApproval`'s own
|
|
740
|
+
* timeout branch).
|
|
741
|
+
* - A stream-based session (the dormant `needs_approval` path in `pump()`,
|
|
742
|
+
* now dispatched via `requestApproval` exactly like a real out-of-band
|
|
743
|
+
* approval) resolves ONLY through its own in-process `resolveApproval()`
|
|
744
|
+
* call — nothing else ever touches `deps.approvalRegistry` for it, so
|
|
745
|
+
* without this call its registry entry and `active.pendingApprovalId`
|
|
746
|
+
* would otherwise linger until this approval's own timeout (or the task
|
|
747
|
+
* finishing) instead of clearing the moment the decision actually lands
|
|
748
|
+
* — which would leave any OTHER approval queued behind it
|
|
749
|
+
* (`active.approvalQueue`) stuck waiting for that same timeout.
|
|
750
|
+
*
|
|
751
|
+
* Called from `handleApprove`/`handleReject` AFTER `active.session
|
|
752
|
+
* .resolveApproval()` has already been given the decision — never before,
|
|
753
|
+
* since for the channel-based case that call is what actually resolves
|
|
754
|
+
* the registry entry `pendingId` names.
|
|
755
|
+
*
|
|
756
|
+
* CRITICAL follow-up to finding 1 above: `pendingId` is a required
|
|
757
|
+
* parameter — deliberately NOT read from `active.pendingApprovalId` inside
|
|
758
|
+
* this method (indeed, this method no longer takes `active` at all). For a
|
|
759
|
+
* channel-based session (claude), the `await active.session
|
|
760
|
+
* .resolveApproval()` in `handleApprove`/`handleReject` BELOW THIS CALL is
|
|
761
|
+
* exactly what synchronously drives `ctx.approvalChannel.resolve` ->
|
|
762
|
+
* `approvalRegistry.resolve(A)` -> A's own `onResolve`
|
|
763
|
+
* (`dispatchApproval` above) -> `dispatchNextQueuedApproval` — and that
|
|
764
|
+
* last step, still inside the SAME synchronous call and therefore still
|
|
765
|
+
* strictly BEFORE the caller's own `await` settles, dispatches the next
|
|
766
|
+
* queued approval (B) and reassigns `active.pendingApprovalId = B`. A
|
|
767
|
+
* caller that read `active.pendingApprovalId` only AFTER that `await`
|
|
768
|
+
* returned (as this method itself used to, before it took `pendingId` as a
|
|
769
|
+
* parameter) would therefore observe B, not A — resolving B (silently,
|
|
770
|
+
* with A's decision: an auto-approve or a force-reject of an approval no
|
|
771
|
+
* one ever actually decided) instead of the already-gone entry for A this
|
|
772
|
+
* call is actually meant to (harmlessly) no-op against. Callers now
|
|
773
|
+
* capture the target id BEFORE that await (`handleApprove`/`handleReject`
|
|
774
|
+
* below) so this can only ever be asked to resolve the id it was meant to
|
|
775
|
+
* all along. See `task-runner-approval.test.ts`'s channel-routing
|
|
776
|
+
* regression test for this exact interleaving reproduced end to end.
|
|
777
|
+
*/
|
|
778
|
+
private clearPendingApproval;
|
|
779
|
+
/**
|
|
780
|
+
* Protocol §5 approval flow: the server's own state already moved
|
|
781
|
+
* `AwaitApproval -> Running` before this best-effort notification arrives
|
|
782
|
+
* (§4) — resuming the session is what makes `task.progress` continue.
|
|
783
|
+
*
|
|
784
|
+
* M4 Phase 3 hardening (orchestrator-directed fix): a wire `task.approve`
|
|
785
|
+
* can legitimately arrive AFTER a different, faster path (a racing local
|
|
786
|
+
* `approvals.resolve` over the control socket, or this exact message
|
|
787
|
+
* redelivered) already resolved the SAME approval — `ApprovalRegistry`'s
|
|
788
|
+
* own "first resolution wins" guarantee means `session.resolveApproval()`
|
|
789
|
+
* throws {@link NoPendingApprovalError} for that loser, not because
|
|
790
|
+
* anything is actually wrong. Before this fix, ANY thrown error here
|
|
791
|
+
* (stale or genuine) failed the whole task — for the stale case that
|
|
792
|
+
* meant a task the winning path had ALREADY correctly resumed (and which
|
|
793
|
+
* may go on to complete normally) got marked `Failed` anyway, purely
|
|
794
|
+
* because a second, now-meaningless notification arrived late. Stale is
|
|
795
|
+
* now an audit-only no-op; a genuine failure (the session itself
|
|
796
|
+
* couldn't resume for some real reason) still fails the task exactly as
|
|
797
|
+
* before.
|
|
798
|
+
*/
|
|
799
|
+
private handleApprove;
|
|
800
|
+
/**
|
|
801
|
+
* Protocol §5 approval flow: the server's own state already moved
|
|
802
|
+
* `AwaitApproval -> Failed` before this best-effort notification arrives
|
|
803
|
+
* (§4) — the daemon's job is just to stop the session and prove it via
|
|
804
|
+
* `task.fail`.
|
|
805
|
+
*
|
|
806
|
+
* M4 Phase 3 hardening (orchestrator-directed fix): same race as
|
|
807
|
+
* `handleApprove` above, but the pre-fix bug here was worse — this method
|
|
808
|
+
* unconditionally interrupted the session and sent `task.fail` regardless
|
|
809
|
+
* of whether `resolveApproval` even threw, so a stale/late wire
|
|
810
|
+
* `task.reject` (the local CLI, or a racing wire approve, already
|
|
811
|
+
* resolved this exact approval a different way) would tear down and fail
|
|
812
|
+
* a task that was already correctly approved and possibly still running
|
|
813
|
+
* fine. Now: a {@link NoPendingApprovalError} short-circuits to an
|
|
814
|
+
* audit-only no-op BEFORE the interrupt/fail/finish sequence — nothing
|
|
815
|
+
* about this task's state is touched. Any OTHER outcome (success, or a
|
|
816
|
+
* genuine non-staleness error) falls through to the existing
|
|
817
|
+
* interrupt+`task.fail`+finish sequence unchanged: the server's own
|
|
818
|
+
* record already moved `AwaitApproval -> Failed` for a REAL reject
|
|
819
|
+
* (§4's "server state is authoritative on its own action" rule), so the
|
|
820
|
+
* daemon must still conform to that regardless of whether telling the
|
|
821
|
+
* session about it succeeded.
|
|
822
|
+
*/
|
|
823
|
+
private handleReject;
|
|
824
|
+
/** Pre-claim, fail-closed rejection (protocol §3.2) — never claims first. */
|
|
825
|
+
private decline;
|
|
826
|
+
private fail;
|
|
827
|
+
private observeGit;
|
|
828
|
+
private updateGitPhaseBestEffort;
|
|
829
|
+
private finish;
|
|
830
|
+
/** M3-B: bounded insert for `finishedTaskIds` — see its class-level doc comment and `MAX_TRACKED_TASK_IDS`. Evicts the oldest (first-inserted) entry once over cap, same idiom as `ConnectionHub.checkAndRecordDuplicate` (packages/server/src/hub.ts). */
|
|
831
|
+
private addFinishedTaskId;
|
|
832
|
+
/** `reuseDir`, when set (a known sessionRef's recorded workspace), is used verbatim instead of a fresh `workspaceRoot/<taskId>` directory — `mkdir recursive` is idempotent either way, so ensuring-exists is safe to do unconditionally. */
|
|
833
|
+
private resolveWorkspaceDir;
|
|
834
|
+
/**
|
|
835
|
+
* M5 batch-3 (workstream 1): selects which adapter runs this offer, now
|
|
836
|
+
* gated on both PRESENCE (`adapter.detect()`, as before) and CAPABILITY
|
|
837
|
+
* (`adapterSupportsMode` — can this adapter even express `policyMode`?
|
|
838
|
+
* new in this batch) — pre-claim, in both the explicit-runtime and
|
|
839
|
+
* auto-select branches.
|
|
840
|
+
*
|
|
841
|
+
* Explicit-runtime branch (`requestedRuntime` set): semantics otherwise
|
|
842
|
+
* unchanged from before this batch — allowlist and known-adapter checks
|
|
843
|
+
* first, THEN the new capability check, THEN presence. A capability
|
|
844
|
+
* mismatch here is a permanent characteristic of naming THIS runtime with
|
|
845
|
+
* THIS policy (e.g. pi never supports `confirm`, on any device, by
|
|
846
|
+
* design — `pi/permission-mapping.ts`) — `retryable: false`, the same
|
|
847
|
+
* class as "not in allowlist"/"unknown runtime" above it, since retrying
|
|
848
|
+
* this exact (runtime, mode) pair anywhere changes nothing.
|
|
849
|
+
*
|
|
850
|
+
* Auto-select branch (`requestedRuntime` absent): candidates are ordered
|
|
851
|
+
* by `runtimePreference` (default {@link DEFAULT_RUNTIME_PREFERENCE}) —
|
|
852
|
+
* see `orderByPreference` — then walked in that order; a candidate that
|
|
853
|
+
* can't express `policyMode` is skipped (not detected at all — capability
|
|
854
|
+
* is checked first, cheaper than a real subprocess probe) and the walk
|
|
855
|
+
* continues down the preference order, exactly as "skip non-supporting
|
|
856
|
+
* adapters and continue down the order" describes. If NOTHING eligible
|
|
857
|
+
* supports the mode, `retryable: true` — unlike the explicit branch, this
|
|
858
|
+
* is device-specific (which runtimes happen to be installed here), so a
|
|
859
|
+
* different device's installed runtime set might satisfy it.
|
|
860
|
+
*/
|
|
861
|
+
private pickAdapter;
|
|
862
|
+
}
|