@zhuxixi/pi-agent-board 0.6.2 → 0.8.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/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -132,9 +132,9 @@ export async function attach(
|
|
|
132
132
|
|
|
133
133
|
const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
|
|
134
134
|
if (plan.plan === "open-pty") {
|
|
135
|
-
service.markVisited?.(viewId);
|
|
135
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
136
136
|
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
137
|
-
service.markVisited?.(viewId);
|
|
137
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
138
138
|
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
139
139
|
}
|
|
140
140
|
if (plan.plan === "session-switch") {
|
|
@@ -144,7 +144,7 @@ export async function attach(
|
|
|
144
144
|
return { action: "none" };
|
|
145
145
|
}
|
|
146
146
|
const name = latest.meta.name;
|
|
147
|
-
service.markVisited?.(viewId);
|
|
147
|
+
void service.markVisited?.(viewId)?.catch(() => {});
|
|
148
148
|
const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
|
|
149
149
|
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
150
150
|
withSession: async (replaced) => {
|
|
@@ -212,8 +212,8 @@ export function installBackToDashboard(
|
|
|
212
212
|
try {
|
|
213
213
|
let selectedId = currentViewId(ctx, service);
|
|
214
214
|
while (true) {
|
|
215
|
-
if (selectedId) service.markVisited?.(selectedId);
|
|
216
|
-
service.reconcile();
|
|
215
|
+
if (selectedId) void service.markVisited?.(selectedId)?.catch(() => {});
|
|
216
|
+
void service.reconcile().catch(() => {});
|
|
217
217
|
const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
|
|
218
218
|
if (result.action !== "attach") return;
|
|
219
219
|
selectedId = result.viewId;
|
package/src/commands/bg.ts
CHANGED
|
@@ -51,7 +51,8 @@ async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts:
|
|
|
51
51
|
},
|
|
52
52
|
});
|
|
53
53
|
const model = modelRef(ctx.model as any);
|
|
54
|
-
|
|
54
|
+
// adoptSession is async (routes through the view-state coordinator, issue #91).
|
|
55
|
+
const adopted = await service.adoptSession({
|
|
55
56
|
sessionFile,
|
|
56
57
|
cwd: ctx.cwd,
|
|
57
58
|
model,
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision layer for the control-command lifecycle (issue #91 Phase 5, spec D4).
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the command envelope shape, per-type ack-stage
|
|
5
|
+
* legality, retry/dedup rules, the resize latest-wins tracker, and the durable
|
|
6
|
+
* command journal's record shapes/GC/unresolved derivation. The runner shell
|
|
7
|
+
* (runner/pty-runner.mjs) owns all side effects — sockets, files, child writes,
|
|
8
|
+
* process lifecycle — and calls into this module for every decision; the UI
|
|
9
|
+
* client and the service's durable follow-up path consume the same table so
|
|
10
|
+
* both ends agree on semantics by construction.
|
|
11
|
+
*
|
|
12
|
+
* No fs, no net, no timers, no Date.now(): every timestamp is an explicit
|
|
13
|
+
* argument, so decisions are deterministic and exhaustively unit-testable
|
|
14
|
+
* (same pattern as state-commands.mjs from Phase 2).
|
|
15
|
+
*
|
|
16
|
+
* ## Envelope
|
|
17
|
+
*
|
|
18
|
+
* Reliable commands (`input` durable follow-up, `terminate`, `reconcile`) and
|
|
19
|
+
* transient controls (`resize`, keystroke `input`, `interrupt`, `detach`) all
|
|
20
|
+
* carry `{commandId, clientId, seq, viewId, instanceId}`. `seq` is a
|
|
21
|
+
* connection-scoped ordering aid ONLY — dedup and retry decisions key on the
|
|
22
|
+
* stable `commandId`, never on `seq` (spec D4, binding). A message without a
|
|
23
|
+
* `commandId` marker is a legacy message: `validateCommandEnvelope` reports it
|
|
24
|
+
* as passthrough, never as an error, so pre-phase-5 UIs keep working verbatim.
|
|
25
|
+
*
|
|
26
|
+
* ## Ack stages
|
|
27
|
+
*
|
|
28
|
+
* - `accepted` — durable commands only: the command is recorded in the durable
|
|
29
|
+
* command journal. Never emitted for transient controls.
|
|
30
|
+
* - `applied` — the underlying action ran; carries the ACTUAL applied value
|
|
31
|
+
* (resize: real PTY cols/rows post-clamp).
|
|
32
|
+
* - `observed` — structured observation evidence only. For terminate on the
|
|
33
|
+
* owned main the evidence is `runnerFinalizing: true` (the finishHost ladder
|
|
34
|
+
* destroys client sockets before the child exits, so a post-exit
|
|
35
|
+
* `exitConfirmed` ack would be undeliverable there); the legacy main sends
|
|
36
|
+
* `exitConfirmed` after the child exit is confirmed. Either field is
|
|
37
|
+
* terminal evidence for terminate. `resize` is NEVER observed (calling
|
|
38
|
+
* child.resize() does not mean the child finished rendering); `input` is
|
|
39
|
+
* never observed either (no stage may claim the child processed the bytes);
|
|
40
|
+
* `detach` has no observed
|
|
41
|
+
* stage (socket write success is not a child state change).
|
|
42
|
+
* - `superseded` — resize latest-wins terminal state, carries `byCommandId`.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Control command types governed by this lifecycle (spec D4 table). */
|
|
46
|
+
export const CONTROL_COMMAND_TYPES = Object.freeze([
|
|
47
|
+
"input",
|
|
48
|
+
"resize",
|
|
49
|
+
"interrupt",
|
|
50
|
+
"terminate",
|
|
51
|
+
"detach",
|
|
52
|
+
"reconcile",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** Ack stages (spec D4). `superseded` is a terminal state, not a delivery stage. */
|
|
56
|
+
export const ACK_STAGES = Object.freeze(["accepted", "applied", "observed", "superseded"]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Error taxonomy for `{type:"error", code, commandId?}` replies to enveloped
|
|
60
|
+
* control commands (spec D4; ruling 2 enumeration). Every code is terminal for
|
|
61
|
+
* the correlating client EXCEPT `host_starting` (bounded retry with fresh
|
|
62
|
+
* commandIds is the runner's documented starting-window contract). Clients
|
|
63
|
+
* must consume a taxonomy error carrying a commandId: clear the pending
|
|
64
|
+
* correlation and surface `cmdAck {stage:"error", code}` — a swallowed error
|
|
65
|
+
* leaves the command pending forever.
|
|
66
|
+
*
|
|
67
|
+
* - `envelope_invalid` — the envelope failed validation (missing/ill-typed
|
|
68
|
+
* fields, listed in `errors`). The command had NO effect. Retrying the same
|
|
69
|
+
* bytes is futile; this is a caller bug.
|
|
70
|
+
* - `instance_mismatch` — the command's instanceId is a foreign fence. The
|
|
71
|
+
* command had NO effect on this runner. `currentInstanceId` is the recovery
|
|
72
|
+
* signal (re-reconcile against it).
|
|
73
|
+
* - `host_starting` — the child is not ready (starting window). The command
|
|
74
|
+
* had NO effect. Retry with a FRESH commandId is the documented contract
|
|
75
|
+
* (the same commandId is not journaled, so reuse would also be safe, but
|
|
76
|
+
* fresh ids keep ack correlation unambiguous).
|
|
77
|
+
* - `journal_unavailable` — a durable accept was REFUSED because the command
|
|
78
|
+
* journal could not be written. Nothing was journaled, nothing applied;
|
|
79
|
+
* the accepted stage would have been a lie. Retry is safe (fresh accept).
|
|
80
|
+
* - `command_failed` — the runner-side action failed after (or without) an
|
|
81
|
+
* accept. Reconcile by commandId BEFORE retrying: if the command was
|
|
82
|
+
* journaled, a blind re-send returns only the cached stage and never
|
|
83
|
+
* re-applies — the honest resolution is reconcile-then-decide.
|
|
84
|
+
*
|
|
85
|
+
* Not listed here: an out-of-order `seq` is dropped REPLY-LESS (diagnostic
|
|
86
|
+
* only, `checkSeq` contract) — it is an ordering aid, never a command
|
|
87
|
+
* rejection, and carries no commandId to correlate.
|
|
88
|
+
*/
|
|
89
|
+
export const CONTROL_ERROR_CODES = Object.freeze({
|
|
90
|
+
envelope_invalid: "envelope failed validation; command had no effect; caller bug",
|
|
91
|
+
instance_mismatch: "foreign instance fence; command had no effect; currentInstanceId is the recovery signal",
|
|
92
|
+
host_starting: "child not ready; command had no effect; bounded retry with fresh commandIds",
|
|
93
|
+
journal_unavailable: "durable accept refused (journal write failed); nothing journaled or applied; retry safe",
|
|
94
|
+
command_failed: "runner-side action failed; reconcile by commandId before retry",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Error codes after which a client-side retry chain must NOT continue. */
|
|
98
|
+
export const TERMINAL_ERROR_CODES = Object.freeze([
|
|
99
|
+
"envelope_invalid",
|
|
100
|
+
"instance_mismatch",
|
|
101
|
+
"journal_unavailable",
|
|
102
|
+
"command_failed",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Per-type delivery semantics — BINDING for runner (Task 2), UI client (Task 3)
|
|
107
|
+
* and service follow-up (Task 4). Stage legality is what classifyCommandAck
|
|
108
|
+
* enforces; `retry` names the rule retryPolicy implements.
|
|
109
|
+
*/
|
|
110
|
+
export const COMMAND_SEMANTICS = Object.freeze({
|
|
111
|
+
input: Object.freeze({
|
|
112
|
+
durable: "conditional", // requestId/commandId-tagged = durable follow-up; bare = keystroke
|
|
113
|
+
stages: Object.freeze({ accepted: true, applied: true, observed: false, superseded: false }),
|
|
114
|
+
appliedValue: null, // child.write ran; no stage may claim the child processed the bytes
|
|
115
|
+
retry: "conditional", // keystroke: never replays; durable: reconcile-query-first, then dedup-protected
|
|
116
|
+
}),
|
|
117
|
+
resize: Object.freeze({
|
|
118
|
+
durable: "no",
|
|
119
|
+
stages: Object.freeze({ accepted: false, applied: true, observed: false, superseded: true }),
|
|
120
|
+
appliedValue: "pty_dims", // real PTY cols/rows post-clamp; never a render claim
|
|
121
|
+
retry: "latest_wins", // same commandId → cached result; new size → new commandId
|
|
122
|
+
}),
|
|
123
|
+
interrupt: Object.freeze({
|
|
124
|
+
durable: "no",
|
|
125
|
+
stages: Object.freeze({ accepted: false, applied: true, observed: false, superseded: false }),
|
|
126
|
+
appliedValue: null,
|
|
127
|
+
retry: "transient", // never after disconnect; a lost ESC is not worth a replay risk
|
|
128
|
+
}),
|
|
129
|
+
terminate: Object.freeze({
|
|
130
|
+
durable: "no",
|
|
131
|
+
stages: Object.freeze({ accepted: false, applied: true, observed: true, superseded: false }),
|
|
132
|
+
appliedValue: "termination_started",
|
|
133
|
+
retry: "idempotent", // repeats return current lifecycle state
|
|
134
|
+
}),
|
|
135
|
+
detach: Object.freeze({
|
|
136
|
+
durable: "no",
|
|
137
|
+
stages: Object.freeze({ accepted: false, applied: true, observed: false, superseded: false }),
|
|
138
|
+
appliedValue: "detach_accepted",
|
|
139
|
+
retry: "idempotent",
|
|
140
|
+
}),
|
|
141
|
+
reconcile: Object.freeze({
|
|
142
|
+
durable: "no",
|
|
143
|
+
stages: Object.freeze({ accepted: false, applied: false, observed: false, superseded: false }),
|
|
144
|
+
appliedValue: null, // reconcile answers with reconcile_result, not cmd_ack stages
|
|
145
|
+
retry: "readonly", // idempotent baseline read; retrying it is safe
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Classify a message's envelope status. The passthrough rule is absolute: a
|
|
151
|
+
* message WITHOUT a non-empty `commandId` is legacy — reported as
|
|
152
|
+
* `{enveloped: false}` with NO errors, never validated further, never errored,
|
|
153
|
+
* so pre-phase-5 clients are untouched regardless of what other fields they
|
|
154
|
+
* carry. Only a message that DOES carry a `commandId` is held to the full
|
|
155
|
+
* envelope contract; any gap there is a client bug worth surfacing.
|
|
156
|
+
*/
|
|
157
|
+
export function validateCommandEnvelope(msg) {
|
|
158
|
+
if (!msg || typeof msg !== "object") return { enveloped: false, errors: ["msg_not_object"] };
|
|
159
|
+
if (typeof msg.commandId !== "string" || msg.commandId.length === 0) {
|
|
160
|
+
return { enveloped: false, errors: [] };
|
|
161
|
+
}
|
|
162
|
+
const errors = [];
|
|
163
|
+
if (!Number.isInteger(msg.seq) || msg.seq < 1) errors.push("seq_invalid");
|
|
164
|
+
if (typeof msg.clientId !== "string" || msg.clientId.length === 0) errors.push("clientid_missing");
|
|
165
|
+
if (typeof msg.viewId !== "string" || msg.viewId.length === 0) errors.push("viewid_missing");
|
|
166
|
+
if (typeof msg.instanceId !== "string" || msg.instanceId.length === 0) errors.push("instanceid_missing");
|
|
167
|
+
if (!CONTROL_COMMAND_TYPES.includes(msg.type)) errors.push("type_not_control");
|
|
168
|
+
return { enveloped: true, errors };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build an enveloped control command. Throws on missing envelope fields, a
|
|
173
|
+
* non-control type, or payload keys colliding with envelope fields (`type`,
|
|
174
|
+
* `commandId`, `clientId`, `seq`, `viewId`, `instanceId`): all are programmer
|
|
175
|
+
* errors at the call site, not runtime input handling — a collision would
|
|
176
|
+
* silently corrupt the envelope, so it fails loud instead.
|
|
177
|
+
*/
|
|
178
|
+
const ENVELOPE_OWNED_KEYS = Object.freeze([
|
|
179
|
+
"type",
|
|
180
|
+
"commandId",
|
|
181
|
+
"clientId",
|
|
182
|
+
"seq",
|
|
183
|
+
"viewId",
|
|
184
|
+
"instanceId",
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
export function encodeCommand(type, payload, envelope) {
|
|
188
|
+
if (!CONTROL_COMMAND_TYPES.includes(type)) {
|
|
189
|
+
throw new TypeError(`encodeCommand: unknown control type ${JSON.stringify(type)}`);
|
|
190
|
+
}
|
|
191
|
+
for (const field of ["commandId", "clientId", "viewId", "instanceId"]) {
|
|
192
|
+
if (typeof envelope?.[field] !== "string" || envelope[field].length === 0) {
|
|
193
|
+
throw new TypeError(`encodeCommand: ${field} must be a non-empty string`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!Number.isInteger(envelope?.seq) || envelope.seq < 1) {
|
|
197
|
+
throw new TypeError("encodeCommand: seq must be an integer >= 1");
|
|
198
|
+
}
|
|
199
|
+
if (payload != null) {
|
|
200
|
+
const collisions = Object.keys(payload).filter((key) => ENVELOPE_OWNED_KEYS.includes(key));
|
|
201
|
+
if (collisions.length > 0) {
|
|
202
|
+
throw new TypeError(`encodeCommand: payload collides with envelope-owned keys: ${collisions.join(", ")}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return Object.freeze({
|
|
206
|
+
type,
|
|
207
|
+
commandId: envelope.commandId,
|
|
208
|
+
clientId: envelope.clientId,
|
|
209
|
+
seq: envelope.seq,
|
|
210
|
+
viewId: envelope.viewId,
|
|
211
|
+
instanceId: envelope.instanceId,
|
|
212
|
+
...(payload ?? {}),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Validate/classify an ack record against the command type's stage semantics.
|
|
218
|
+
* Returns `{ok: true, stage, ...}` or `{ok: false, reason}` — the runner emits
|
|
219
|
+
* only classified acks; the client classifies before trusting one.
|
|
220
|
+
*/
|
|
221
|
+
export function classifyCommandAck(type, ack) {
|
|
222
|
+
const sem = COMMAND_SEMANTICS[type];
|
|
223
|
+
if (!sem) return { ok: false, reason: "unknown_type" };
|
|
224
|
+
if (!ack || typeof ack !== "object") return { ok: false, reason: "ack_not_object" };
|
|
225
|
+
const commandId = typeof ack.commandId === "string" && ack.commandId ? ack.commandId : null;
|
|
226
|
+
switch (ack.stage) {
|
|
227
|
+
case "accepted": {
|
|
228
|
+
if (!sem.stages.accepted) return { ok: false, reason: "accepted_not_applicable" };
|
|
229
|
+
// `accepted` exists only for durable delivery: it means "recorded in
|
|
230
|
+
// the durable journal". A bare keystroke input must never produce it.
|
|
231
|
+
if (type === "input" && ack.durable !== true) {
|
|
232
|
+
return { ok: false, reason: "accepted_requires_durable" };
|
|
233
|
+
}
|
|
234
|
+
return { ok: true, stage: "accepted", commandId };
|
|
235
|
+
}
|
|
236
|
+
case "applied": {
|
|
237
|
+
if (!sem.stages.applied) return { ok: false, reason: "applied_not_applicable" };
|
|
238
|
+
if (sem.appliedValue === "pty_dims") {
|
|
239
|
+
if (!Number.isInteger(ack.cols) || !Number.isInteger(ack.rows)) {
|
|
240
|
+
return { ok: false, reason: "applied_requires_dims" };
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, stage: "applied", commandId, value: { cols: ack.cols, rows: ack.rows } };
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, stage: "applied", commandId };
|
|
245
|
+
}
|
|
246
|
+
case "observed": {
|
|
247
|
+
if (!sem.stages.observed) return { ok: false, reason: "observed_not_applicable" };
|
|
248
|
+
// Structured evidence only (spec D4), never a timer or an assumption.
|
|
249
|
+
// Terminate accepts TWO evidence forms: a confirmed child exit, or the
|
|
250
|
+
// runner's own lifecycle-state confirmation — an owned runner finalizes
|
|
251
|
+
// in lockstep with the child and structurally cannot send after the
|
|
252
|
+
// exit lands, so its finalizing state is the best deliverable evidence.
|
|
253
|
+
if (type === "terminate" && ack.exitConfirmed !== true && ack.runnerFinalizing !== true) {
|
|
254
|
+
return { ok: false, reason: "observed_requires_exit_confirmation" };
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, stage: "observed", commandId };
|
|
257
|
+
}
|
|
258
|
+
case "superseded": {
|
|
259
|
+
if (!sem.stages.superseded) return { ok: false, reason: "superseded_not_applicable" };
|
|
260
|
+
if (typeof ack.byCommandId !== "string" || ack.byCommandId.length === 0) {
|
|
261
|
+
return { ok: false, reason: "superseded_requires_by" };
|
|
262
|
+
}
|
|
263
|
+
if (ack.byCommandId === ack.commandId) return { ok: false, reason: "superseded_self" };
|
|
264
|
+
return { ok: true, stage: "superseded", commandId, byCommandId: ack.byCommandId };
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return { ok: false, reason: "unknown_stage" };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Retry/dedup decision per command type given the observed history.
|
|
273
|
+
*
|
|
274
|
+
* `history` fields: `{durable?, disconnected?, timedOut?, query?}` where
|
|
275
|
+
* `query` is a reconcile/query outcome for a durable command: `"applied"`
|
|
276
|
+
* (already applied — done), `"accepted_unknown"` (accepted but the applying
|
|
277
|
+
* runner died before `applied` — the spec §10 window; NEVER auto-replay),
|
|
278
|
+
* `"unknown"` (the runner never saw this commandId — retry is safe because
|
|
279
|
+
* commandId dedup protects against double delivery).
|
|
280
|
+
*/
|
|
281
|
+
export function retryPolicy(type, history = {}) {
|
|
282
|
+
switch (type) {
|
|
283
|
+
case "input": {
|
|
284
|
+
if (history.durable !== true) return { action: "never", reason: "keystroke_never_replays" };
|
|
285
|
+
if (history.query === "applied") return { action: "done", reason: "already_applied" };
|
|
286
|
+
if (history.query === "accepted_unknown") {
|
|
287
|
+
return { action: "ambiguous", reason: "accepted_write_window_lost" };
|
|
288
|
+
}
|
|
289
|
+
if (history.query === "unknown") return { action: "retry_same_command", reason: "dedup_protects" };
|
|
290
|
+
// Timeout or disconnect without a query result: the client cannot
|
|
291
|
+
// assume failure (spec D4) — reconcile first.
|
|
292
|
+
return { action: "query_then_decide", reason: "timeout_cannot_assume_failure" };
|
|
293
|
+
}
|
|
294
|
+
case "resize":
|
|
295
|
+
// The user asked for a size; a retry of the OLD command is pointless —
|
|
296
|
+
// send the current size as a NEW command (latest-wins supersedes).
|
|
297
|
+
return { action: "new_command", reason: "latest_wins" };
|
|
298
|
+
case "interrupt":
|
|
299
|
+
return { action: "never", reason: "transient_lost_interrupt_not_replayed" };
|
|
300
|
+
case "terminate":
|
|
301
|
+
case "detach":
|
|
302
|
+
return { action: "retry_same_command", reason: "idempotent" };
|
|
303
|
+
case "reconcile":
|
|
304
|
+
return { action: "retry_same_command", reason: "readonly_baseline_read" };
|
|
305
|
+
default:
|
|
306
|
+
return { action: "never", reason: "unknown_type" };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Dedup key for control commands: the stable `commandId`, nothing else.
|
|
312
|
+
* `seq` is connection-scoped ordering and must never participate (spec D4).
|
|
313
|
+
* Returns null for legacy (envelope-less) messages — they are not deduped.
|
|
314
|
+
*/
|
|
315
|
+
export function dedupKey(msg) {
|
|
316
|
+
return typeof msg?.commandId === "string" && msg.commandId.length > 0 ? msg.commandId : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Client-side resize latest-wins mirror. The runner is authoritative for
|
|
321
|
+
* supersession (receipt order on the socket); this tracker lets the UI know
|
|
322
|
+
* which of its own resizes are dead (superseded) and reuse results when the
|
|
323
|
+
* same commandId is re-requested.
|
|
324
|
+
*
|
|
325
|
+
* - `track({commandId, clientId, cols, rows})` → `{superseded: [{commandId,
|
|
326
|
+
* byCommandId}], duplicate}` — a NEW size from a client supersedes that
|
|
327
|
+
* client's still-unapplied pending resize; re-tracking the same commandId
|
|
328
|
+
* (send retry) is a duplicate, superseding nothing.
|
|
329
|
+
* - `applied(commandId, cols, rows)` → cache the runner's actual applied dims.
|
|
330
|
+
* - `resultFor(commandId)` → cached `{cols, rows}` for same-commandId
|
|
331
|
+
* re-requests, or undefined.
|
|
332
|
+
*/
|
|
333
|
+
export const RESIZE_TRACKER_KEEP_DEFAULT = 256;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Latest-wins resize tracking with a FIFO retention bound (CR R1 advisory:
|
|
337
|
+
* the runner is a long-lived process — unbounded knownIds/results grew the
|
|
338
|
+
* map forever, ~100B per resize). Mirrors the durable journal's keep pattern:
|
|
339
|
+
* when the insertion-order log exceeds the bound, the OLDEST commandIds are
|
|
340
|
+
* forgotten everywhere (knownIds, results, and any pendingByClient entry that
|
|
341
|
+
* still points at one — its supersede chain ends with the eviction, which is
|
|
342
|
+
* fine: a 256-resize-old pending command is unreachable by construction).
|
|
343
|
+
*
|
|
344
|
+
* @param {{keep?: number}} [opts]
|
|
345
|
+
*/
|
|
346
|
+
export function createResizeTracker({ keep = RESIZE_TRACKER_KEEP_DEFAULT } = {}) {
|
|
347
|
+
/** clientId → the single newest pending command (latest-wins). */
|
|
348
|
+
const pendingByClient = new Map();
|
|
349
|
+
/** every retained commandId (duplicate detection across clients) */
|
|
350
|
+
const knownIds = new Set();
|
|
351
|
+
/** commandId → applied dims */
|
|
352
|
+
const results = new Map();
|
|
353
|
+
/** insertion order for FIFO eviction */
|
|
354
|
+
const order = [];
|
|
355
|
+
const forgetOldest = () => {
|
|
356
|
+
while (order.length > keep) {
|
|
357
|
+
const oldest = order.shift();
|
|
358
|
+
knownIds.delete(oldest);
|
|
359
|
+
results.delete(oldest);
|
|
360
|
+
for (const [clientId, pending] of pendingByClient) {
|
|
361
|
+
if (pending.commandId === oldest) pendingByClient.delete(clientId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
return {
|
|
366
|
+
track({ commandId, clientId, cols, rows }) {
|
|
367
|
+
if (typeof commandId !== "string" || commandId.length === 0) {
|
|
368
|
+
throw new TypeError("resizeTracker.track: commandId required");
|
|
369
|
+
}
|
|
370
|
+
if (knownIds.has(commandId)) return { superseded: [], duplicate: true };
|
|
371
|
+
knownIds.add(commandId);
|
|
372
|
+
order.push(commandId);
|
|
373
|
+
const superseded = [];
|
|
374
|
+
const prev = pendingByClient.get(clientId);
|
|
375
|
+
if (prev) superseded.push({ commandId: prev.commandId, byCommandId: commandId });
|
|
376
|
+
pendingByClient.set(clientId, { commandId, cols, rows });
|
|
377
|
+
forgetOldest();
|
|
378
|
+
return { superseded, duplicate: false };
|
|
379
|
+
},
|
|
380
|
+
applied(commandId, cols, rows) {
|
|
381
|
+
results.set(commandId, { cols, rows });
|
|
382
|
+
for (const [clientId, pending] of pendingByClient) {
|
|
383
|
+
if (pending.commandId === commandId) pendingByClient.delete(clientId);
|
|
384
|
+
}
|
|
385
|
+
return { ok: true };
|
|
386
|
+
},
|
|
387
|
+
resultFor(commandId) {
|
|
388
|
+
return results.get(commandId);
|
|
389
|
+
},
|
|
390
|
+
size() {
|
|
391
|
+
return order.length;
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Durable command journal (record shapes + GC + unresolved derivation)
|
|
398
|
+
//
|
|
399
|
+
// The runner appends one JSONL line per lifecycle transition of a durable
|
|
400
|
+
// command: `{kind:"accepted", commandId, command, acceptedAt}` on accept and
|
|
401
|
+
// `{kind:"applied", commandId, appliedAt}` once `child.write` ran. On restart
|
|
402
|
+
// the journal is loaded and `journalUnresolved` derives the spec §10
|
|
403
|
+
// "accepted_unknown" set — commands the PREVIOUS runner accepted but whose
|
|
404
|
+
// applied outcome is unknown. These are NEVER auto-replayed (spec §10 binding
|
|
405
|
+
// rule); the service decides per its own queue semantics via reconcile.
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
export const JOURNAL_KEEP_DEFAULT = 256;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Per-connection sequence check (runner-side ordering aid, spec D4). `seq` must
|
|
412
|
+
* be an integer strictly greater than the last accepted seq on the connection;
|
|
413
|
+
* gaps are legal (clients may batch), repeats/regressions are not. Ordering
|
|
414
|
+
* ONLY — dedup keys on `commandId` and never on `seq`.
|
|
415
|
+
*
|
|
416
|
+
* @returns {{ok: true} | {ok: false, reason: "seq_invalid" | "seq_not_monotonic"}}
|
|
417
|
+
*/
|
|
418
|
+
export function checkSeq(lastSeq, seq) {
|
|
419
|
+
if (!Number.isInteger(seq) || seq < 1) return { ok: false, reason: "seq_invalid" };
|
|
420
|
+
if (seq <= lastSeq) return { ok: false, reason: "seq_not_monotonic" };
|
|
421
|
+
return { ok: true };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Validate and append a journal record (pure: returns a new array). Throws on
|
|
426
|
+
* malformed records — a malformed journal line is a runner bug, not input.
|
|
427
|
+
*/
|
|
428
|
+
export function journalAppendRecord(records, record) {
|
|
429
|
+
if (!record || typeof record !== "object") throw new TypeError("journal record must be an object");
|
|
430
|
+
if (record.kind !== "accepted" && record.kind !== "applied") {
|
|
431
|
+
throw new TypeError(`journal record kind must be "accepted"|"applied", got ${JSON.stringify(record.kind)}`);
|
|
432
|
+
}
|
|
433
|
+
if (typeof record.commandId !== "string" || record.commandId.length === 0) {
|
|
434
|
+
throw new TypeError("journal record requires a non-empty commandId");
|
|
435
|
+
}
|
|
436
|
+
if (record.kind === "accepted") {
|
|
437
|
+
if (typeof record.acceptedAt !== "number") throw new TypeError("accepted record requires numeric acceptedAt");
|
|
438
|
+
if (typeof record.command !== "string") throw new TypeError("accepted record requires a command string");
|
|
439
|
+
} else if (typeof record.appliedAt !== "number") {
|
|
440
|
+
throw new TypeError("applied record requires numeric appliedAt");
|
|
441
|
+
}
|
|
442
|
+
return [...records, record];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* GC to the newest `keep` distinct commandIds, dropping WHOLE lifecycles.
|
|
447
|
+
* Invariant: every `commandId` in the result keeps ALL of its records or none.
|
|
448
|
+
* Record-count GC cannot give this guarantee — duplicate accepted records may
|
|
449
|
+
* legitimately follow a command's applied record, so which records survive a
|
|
450
|
+
* trim would depend on append order, and a surviving `accepted` beside a
|
|
451
|
+
dropped `applied` would resurrect a phantom "accepted_unknown" after restart.
|
|
452
|
+
* Group GC removes that dependence entirely.
|
|
453
|
+
*/
|
|
454
|
+
export function journalGc(records, keep = JOURNAL_KEEP_DEFAULT) {
|
|
455
|
+
const lastIndexById = new Map();
|
|
456
|
+
records.forEach((record, index) => lastIndexById.set(record.commandId, index));
|
|
457
|
+
const keepIds = new Set(
|
|
458
|
+
[...lastIndexById.entries()]
|
|
459
|
+
.sort((a, b) => b[1] - a[1])
|
|
460
|
+
.slice(0, keep)
|
|
461
|
+
.map(([id]) => id),
|
|
462
|
+
);
|
|
463
|
+
return records.filter((record) => keepIds.has(record.commandId));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Derive the unresolved set: commands with an `accepted` but no `applied`
|
|
468
|
+
* record, in accept order, deduped by commandId. This is exactly the
|
|
469
|
+
* "accepted_unknown" set reconcile reports after a runner restart.
|
|
470
|
+
*/
|
|
471
|
+
export function journalUnresolved(records) {
|
|
472
|
+
const applied = new Set(records.filter((r) => r.kind === "applied").map((r) => r.commandId));
|
|
473
|
+
const seen = new Set();
|
|
474
|
+
const out = [];
|
|
475
|
+
for (const record of records) {
|
|
476
|
+
if (record.kind !== "accepted") continue;
|
|
477
|
+
if (applied.has(record.commandId) || seen.has(record.commandId)) continue;
|
|
478
|
+
seen.add(record.commandId);
|
|
479
|
+
out.push({ commandId: record.commandId, command: record.command, acceptedAt: record.acceptedAt });
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|