@tea-agent/loop-agent 0.35.4-beta.0 → 0.36.1-beta.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.
Files changed (53) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +67 -0
  3. package/dist/application/task-lifecycle/advance.js +1 -0
  4. package/dist/application/task-lifecycle/observe.js +15 -0
  5. package/dist/application/task-lifecycle/plan-transitions.js +15 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/commands/init-upgrade.js +351 -19
  8. package/dist/commands/init.js +14 -67
  9. package/dist/commands/run-dag-progress.js +14 -0
  10. package/dist/commands/task-advance.js +33 -3
  11. package/dist/executors/dag-pi-executor.js +3 -1
  12. package/dist/shared/operator/capabilities.js +125 -11
  13. package/dist/task/source-prepare/completeness.js +17 -0
  14. package/dist/task/source-prepare/parse-intent.js +15 -1
  15. package/dist/worker/console/chat/artifact-card.js +8 -1
  16. package/dist/worker/console/chat/chat-event-store.js +61 -0
  17. package/dist/worker/console/chat/human-gate-card.js +9 -1
  18. package/dist/worker/console/chat/operation-card.js +71 -2
  19. package/dist/worker/console/chat/pi-runtime.js +69 -30
  20. package/dist/worker/console/chat/routes.js +27 -4
  21. package/dist/worker/console/chat/semantic-activity.js +465 -0
  22. package/dist/worker/console/chat/turn-process.js +31 -12
  23. package/dist/worker/console/operation-run-facts.js +190 -0
  24. package/dist/worker/console/operation-runner.js +107 -6
  25. package/dist/worker/console/operation-wait.js +314 -0
  26. package/dist/worker/console/operator-actions.js +153 -2
  27. package/dist/worker/console/static/assets/index-2OeZODxk.js +57 -0
  28. package/dist/worker/console/static/assets/index-DVJlUL8X.css +1 -0
  29. package/dist/worker/console/static/index.html +2 -2
  30. package/dist/worker/console/static-src/operator-chat/activity-journey.js +125 -0
  31. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +73 -0
  32. package/dist/worker/console/static-src/operator-chat/activity-references.js +20 -0
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +69 -31
  34. package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +24 -0
  35. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +141 -0
  36. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +2 -1
  37. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +59 -0
  38. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +30 -7
  40. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +7 -3
  41. package/dist/worker/observe/static/operator-chrome.js +1 -1
  42. package/dist/workflows/dag/init-hybrid.js +78 -4
  43. package/dist/workflows/dag/node-execution.js +16 -6
  44. package/dist/workflows/dag/retry-policy.js +11 -0
  45. package/docs/README.md +3 -3
  46. package/docs/architecture/evolution.md +102 -67
  47. package/docs/templates/init-managed-agents.md +5 -2
  48. package/harness.json +2 -2
  49. package/package.json +1 -1
  50. package/skills/loop-agent/SKILL.md +1 -0
  51. package/skills/loop-agent/references/command-reference.md +2 -0
  52. package/dist/worker/console/static/assets/index-DRqZiQ7J.css +0 -1
  53. package/dist/worker/console/static/assets/index-DuVLjCIT.js +0 -57
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Trusted run-facts extraction + safe run summary projection
3
+ * (2026-08-14 Operator Chat DAG recovery P0).
4
+ *
5
+ * Invariants:
6
+ * - Only deterministic structured sources are trusted: the standard
7
+ * `OperatorCommandResultV1.result.runSummary` envelope, a structured CLI
8
+ * `runSummary` object, or the rerun standard `newRunId`. Free-text stdout /
9
+ * non-standard JSON is NEVER scanned or guessed for run ids.
10
+ * - The projection uses an independent allowlist with bounded fields/arrays
11
+ * and redacted text; it must never leak raw results, actionParams, CLI
12
+ * args, absolute artifact paths or secrets.
13
+ * - A run summary is a projection only: it can never change the terminal
14
+ * operation state decided by the command envelope/exit facts.
15
+ */
16
+ import { scrubSecrets } from "./chat/explore-tools.js";
17
+ const RUN_SUMMARY_MAX_NODES = 20;
18
+ const RUN_SUMMARY_MAX_TEXT_CHARS = 600;
19
+ const ABSOLUTE_PATH_REDACTION = "[REDACTED-ABS-PATH]";
20
+ function asRecord(value) {
21
+ if (!value || typeof value !== "object" || Array.isArray(value))
22
+ return null;
23
+ return value;
24
+ }
25
+ function redactAbsolutePathSubstrings(value) {
26
+ let redacted = "";
27
+ let cursor = 0;
28
+ while (cursor < value.length) {
29
+ const current = value[cursor] ?? "";
30
+ const next = value[cursor + 1] ?? "";
31
+ const previous = value[cursor - 1] ?? "";
32
+ const windowsDrive = /[A-Za-z]/.test(current) &&
33
+ next === ":" &&
34
+ /[\\/]/.test(value[cursor + 2] ?? "") &&
35
+ (cursor === 0 || !/[A-Za-z0-9_]/.test(previous));
36
+ const uncPath = current === "\\" && next === "\\";
37
+ const posixPath = current === "/" &&
38
+ next !== "/" &&
39
+ (cursor === 0 || (!/[A-Za-z0-9_]/.test(previous) && previous !== "/"));
40
+ if (!windowsDrive && !uncPath && !posixPath) {
41
+ redacted += current;
42
+ cursor += 1;
43
+ continue;
44
+ }
45
+ const quote = previous === '"' || previous === "'" ? previous : undefined;
46
+ let end = cursor;
47
+ while (end < value.length) {
48
+ const char = value[end] ?? "";
49
+ if (quote ? char === quote : /["'<>()[\]{},;]/.test(char))
50
+ break;
51
+ end += 1;
52
+ }
53
+ redacted += ABSOLUTE_PATH_REDACTION;
54
+ cursor = end;
55
+ }
56
+ return redacted;
57
+ }
58
+ function boundedRedactedText(value, maxChars = RUN_SUMMARY_MAX_TEXT_CHARS) {
59
+ if (typeof value !== "string" || !value.trim())
60
+ return undefined;
61
+ const { scrubbed } = scrubSecrets(value);
62
+ const pathRedacted = redactAbsolutePathSubstrings(scrubbed);
63
+ return pathRedacted.length <= maxChars
64
+ ? pathRedacted
65
+ : `${pathRedacted.slice(0, maxChars)}…[truncated]`;
66
+ }
67
+ function safeNode(value) {
68
+ const rec = asRecord(value);
69
+ if (!rec)
70
+ return undefined;
71
+ const id = boundedRedactedText(rec.id, 200);
72
+ const status = boundedRedactedText(rec.status, 100);
73
+ if (!id || !status)
74
+ return undefined;
75
+ return { id, status };
76
+ }
77
+ /** Independent allowlist projection of a structured run summary. */
78
+ export function safeRunSummaryForProjection(value) {
79
+ const rec = asRecord(value);
80
+ if (!rec)
81
+ return undefined;
82
+ const runId = boundedRedactedText(rec.runId, 200);
83
+ if (!runId)
84
+ return undefined;
85
+ const status = boundedRedactedText(rec.status, 100);
86
+ if (!status)
87
+ return undefined;
88
+ const nodes = [];
89
+ if (Array.isArray(rec.nodes)) {
90
+ for (const raw of rec.nodes) {
91
+ if (nodes.length >= RUN_SUMMARY_MAX_NODES)
92
+ break;
93
+ const node = safeNode(raw);
94
+ if (node)
95
+ nodes.push(node);
96
+ }
97
+ }
98
+ const outOfBounds = [];
99
+ if (Array.isArray(rec.outOfBounds)) {
100
+ for (const raw of rec.outOfBounds.slice(0, RUN_SUMMARY_MAX_NODES)) {
101
+ const entry = asRecord(raw);
102
+ if (!entry)
103
+ continue;
104
+ const nodeId = boundedRedactedText(entry.nodeId, 200);
105
+ const failureCategory = boundedRedactedText(entry.failureCategory, 200);
106
+ if (nodeId && failureCategory)
107
+ outOfBounds.push({ nodeId, failureCategory });
108
+ }
109
+ }
110
+ let next;
111
+ const nextRec = asRecord(rec.next);
112
+ if (nextRec) {
113
+ const kind = boundedRedactedText(nextRec.kind, 100);
114
+ if (kind) {
115
+ const description = boundedRedactedText(nextRec.description);
116
+ next = { kind, ...(description ? { description } : {}) };
117
+ }
118
+ }
119
+ const verdict = boundedRedactedText(rec.verdict);
120
+ const failureCategory = boundedRedactedText(rec.failureCategory);
121
+ return {
122
+ runId,
123
+ status,
124
+ nodes,
125
+ ...(verdict !== undefined ? { verdict } : {}),
126
+ ...(failureCategory !== undefined ? { failureCategory } : {}),
127
+ ...(outOfBounds.length > 0 ? { outOfBounds } : {}),
128
+ ...(next ? { next } : {}),
129
+ };
130
+ }
131
+ /**
132
+ * Deterministic trusted-source extraction:
133
+ * 1. `OperatorCommandResultV1.result.runSummary` (schemaVersion=1 envelope);
134
+ * 2. structured CLI JSON `runSummary` object (task advance / report shapes);
135
+ * 3. rerun standard `newRunId` (optionally with its own `runSummary`).
136
+ *
137
+ * Candidates are checked in order; a rerun `newRunId` that disagrees with the
138
+ * accompanying `runSummary.runId` is rejected (no guessing). Free text is
139
+ * never scanned.
140
+ */
141
+ export function extractTrustedRunFacts(result) {
142
+ const root = asRecord(result.json);
143
+ if (!root)
144
+ return undefined;
145
+ // Envelope: { schemaVersion: 1, ok, result: { runSummary } }
146
+ if (root.schemaVersion === 1 && typeof root.ok === "boolean") {
147
+ const payload = asRecord(root.result);
148
+ const runSummaryRaw = payload ? asRecord(payload.runSummary) : null;
149
+ if (runSummaryRaw) {
150
+ const summary = safeRunSummaryForProjection(runSummaryRaw);
151
+ if (summary)
152
+ return { runId: summary.runId, runSummary: summary };
153
+ }
154
+ // Rerun standard newRunId inside the envelope result.
155
+ if (payload && typeof payload.newRunId === "string" && payload.newRunId.trim()) {
156
+ const runId = payload.newRunId.trim();
157
+ const inner = asRecord(payload.runSummary);
158
+ const summary = inner ? safeRunSummaryForProjection(inner) : undefined;
159
+ if (summary && summary.runId !== runId)
160
+ return undefined;
161
+ return summary ? { runId, runSummary: summary } : { runId };
162
+ }
163
+ return undefined;
164
+ }
165
+ // Structured CLI JSON with a top-level runSummary object.
166
+ const directSummary = asRecord(root.runSummary);
167
+ if (directSummary) {
168
+ const summary = safeRunSummaryForProjection(directSummary);
169
+ if (summary) {
170
+ // A rerun newRunId must agree with the summary runId.
171
+ if (typeof root.newRunId === "string" &&
172
+ root.newRunId.trim() &&
173
+ root.newRunId.trim() !== summary.runId) {
174
+ return undefined;
175
+ }
176
+ return { runId: summary.runId, runSummary: summary };
177
+ }
178
+ return undefined;
179
+ }
180
+ // Rerun standard newRunId (top level, non-envelope).
181
+ if (typeof root.newRunId === "string" && root.newRunId.trim()) {
182
+ const runId = root.newRunId.trim();
183
+ const inner = asRecord(root.runSummary);
184
+ const summary = inner ? safeRunSummaryForProjection(inner) : undefined;
185
+ if (summary && summary.runId !== runId)
186
+ return undefined;
187
+ return summary ? { runId, runSummary: summary } : { runId };
188
+ }
189
+ return undefined;
190
+ }
@@ -1,5 +1,6 @@
1
1
  import { clipPreview, } from "./operation-store.js";
2
2
  import { stateEvent } from "./operation-sse.js";
3
+ import { extractTrustedRunFacts } from "./operation-run-facts.js";
3
4
  /**
4
5
  * Execute a queued operation via LoopAgentClient (or inject).
5
6
  * Timeout without proven exit → needs-reconcile (never Cancel).
@@ -74,6 +75,30 @@ export async function runOperation(operationId, deps) {
74
75
  message: chunk,
75
76
  });
76
77
  },
78
+ onHeartbeat: (info) => {
79
+ // P1 (2026-08-13): project sibling CLI heartbeats into canonical
80
+ // operation events so operationWait/operationEventSummary can consume
81
+ // them. The projection is best-effort: a failed append must never
82
+ // terminate the sibling CLI execution (AC-002). The injected test
83
+ // runCommand path has no LoopAgentClient callback guard, so the
84
+ // runner protects itself here.
85
+ try {
86
+ deps.events.append(operationId, {
87
+ at: info.at,
88
+ kind: "heartbeat",
89
+ message: `heartbeat elapsedMs=${info.elapsedMs}`,
90
+ data: {
91
+ elapsedMs: info.elapsedMs,
92
+ action: op.action,
93
+ ...(op.taskId ? { taskId: op.taskId } : {}),
94
+ ...(op.dagRunId ? { dagRunId: op.dagRunId } : {}),
95
+ },
96
+ });
97
+ }
98
+ catch {
99
+ // Heartbeat is derived telemetry; ignore projection failures.
100
+ }
101
+ },
77
102
  });
78
103
  await spawnUpdate;
79
104
  finished = await finalizeFromWorkerResult(operationId, result, deps);
@@ -97,6 +122,38 @@ export async function runOperation(operationId, deps) {
97
122
  }
98
123
  return finished;
99
124
  }
125
+ function taskAdvanceBusinessBlock(operation, envelope) {
126
+ if (!operation || !envelope)
127
+ return undefined;
128
+ if (operation.action !== "taskAdvance" && operation.action !== "dagRunTask") {
129
+ return undefined;
130
+ }
131
+ const result = envelope.result && typeof envelope.result === "object"
132
+ ? envelope.result
133
+ : undefined;
134
+ const lifecycleState = typeof result?.lifecycleState === "string"
135
+ ? result.lifecycleState
136
+ : undefined;
137
+ const blockers = Array.isArray(result?.blockers)
138
+ ? result.blockers
139
+ : [];
140
+ const expectedGateStop = lifecycleState === "awaiting-write-set-approval" && blockers.length === 0;
141
+ if (expectedGateStop)
142
+ return undefined;
143
+ if (lifecycleState !== "intake-incomplete" &&
144
+ blockers.length === 0 &&
145
+ envelope.outcome !== "blocked") {
146
+ return undefined;
147
+ }
148
+ const firstBlocker = blockers[0];
149
+ const code = typeof firstBlocker?.code === "string"
150
+ ? firstBlocker.code
151
+ : "TASK_ADVANCE_BLOCKED";
152
+ const message = typeof firstBlocker?.message === "string"
153
+ ? firstBlocker.message
154
+ : `task advance stopped before runnable DAG completion (lifecycleState=${lifecycleState ?? "unknown"})`;
155
+ return { code, message };
156
+ }
100
157
  async function finalizeFromWorkerResult(operationId, result, deps) {
101
158
  const previewStdout = clipPreview(result.stdout ?? "");
102
159
  const previewStderr = clipPreview(result.stderr ?? "");
@@ -106,6 +163,8 @@ async function finalizeFromWorkerResult(operationId, result, deps) {
106
163
  resultPath: result.artifacts.resultPath,
107
164
  dir: result.artifacts.dir,
108
165
  };
166
+ // Trusted structured run facts only (AC-003): never scanned from free text.
167
+ const runFacts = extractTrustedRunFacts(result);
109
168
  if (result.timedOut) {
110
169
  // Supervisory timeout without classified CLI timeout → needs-reconcile
111
170
  // Only mark timed-out when we have a machine timeout result after exit.
@@ -121,12 +180,35 @@ async function finalizeFromWorkerResult(operationId, result, deps) {
121
180
  ? "CLI timed out with process exit"
122
181
  : "timeout reached without proven process exit; needs reconcile (not cancelled)",
123
182
  result: coerceEnvelope(result),
183
+ ...(runFacts
184
+ ? {
185
+ dagRunId: runFacts.runId,
186
+ ...(runFacts.runSummary ? { runSummary: runFacts.runSummary } : {}),
187
+ }
188
+ : {}),
124
189
  });
125
190
  stateEvent(deps.events, updated, state, updated.errorMessage);
191
+ if (runFacts) {
192
+ appendSafeResultEvent(deps, operationId, state, {
193
+ ok: false,
194
+ exitCode: result.exitCode,
195
+ }, runFacts);
196
+ }
197
+ else {
198
+ deps.events.append(operationId, {
199
+ at: new Date().toISOString(),
200
+ kind: "result",
201
+ state,
202
+ data: { ok: false, exitCode: result.exitCode },
203
+ });
204
+ }
126
205
  return updated;
127
206
  }
128
207
  const envelope = coerceEnvelope(result);
129
- const ok = envelope?.ok === true || (result.ok && result.exitCode === 0);
208
+ const operation = await deps.store.get(operationId);
209
+ const businessBlock = taskAdvanceBusinessBlock(operation, envelope);
210
+ const processOk = envelope?.ok === true || (result.ok && result.exitCode === 0);
211
+ const ok = processOk && !businessBlock;
130
212
  const state = ok ? "succeeded" : "failed";
131
213
  const updated = await deps.store.update(operationId, {
132
214
  state,
@@ -138,21 +220,40 @@ async function finalizeFromWorkerResult(operationId, result, deps) {
138
220
  stdout: previewStdout,
139
221
  stderr: previewStderr,
140
222
  }),
141
- errorCode: ok ? undefined : (envelope?.error?.code ?? "INTERNAL_ERROR"),
223
+ errorCode: ok
224
+ ? undefined
225
+ : (businessBlock?.code ?? envelope?.error?.code ?? "INTERNAL_ERROR"),
142
226
  errorMessage: ok
143
227
  ? undefined
144
- : (envelope?.error?.message ??
228
+ : (businessBlock?.message ??
229
+ envelope?.error?.message ??
145
230
  (previewStderr.trim() ||
146
231
  `command failed with exit ${result.exitCode}`)),
232
+ // Atomic final patch: state/result + trusted dagRunId + safe runSummary
233
+ // in a single per-op write-locked update (AC-004). The runSummary is a
234
+ // projection and never launders the envelope/exit-decided state.
235
+ ...(runFacts
236
+ ? {
237
+ dagRunId: runFacts.runId,
238
+ ...(runFacts.runSummary ? { runSummary: runFacts.runSummary } : {}),
239
+ }
240
+ : {}),
147
241
  });
148
- stateEvent(deps.events, updated, state);
242
+ stateEvent(deps.events, updated, state, updated.errorMessage);
243
+ appendSafeResultEvent(deps, operationId, state, { ok, exitCode: result.exitCode }, runFacts);
244
+ return updated;
245
+ }
246
+ /** Result event carries the same safe summary persisted on the record. */
247
+ function appendSafeResultEvent(deps, operationId, state, base, runFacts) {
149
248
  deps.events.append(operationId, {
150
249
  at: new Date().toISOString(),
151
250
  kind: "result",
152
251
  state,
153
- data: { ok, exitCode: result.exitCode },
252
+ data: {
253
+ ...base,
254
+ ...(runFacts?.runSummary ? { runSummary: runFacts.runSummary } : {}),
255
+ },
154
256
  });
155
- return updated;
156
257
  }
157
258
  function coerceEnvelope(result) {
158
259
  const json = result.json;
@@ -0,0 +1,314 @@
1
+ import { projectOperationEventSummary, projectOperationForChat, } from "./chat/chat-event-store.js";
2
+ import { isTerminalOperationState, } from "./operation-store.js";
3
+ /**
4
+ * P2: read-only event-driven long poll on the canonical operation event ring
5
+ * (2026-08-13 Operator Chat long-run supervision). Server contract bounds:
6
+ * the model-facing `maxWaitMs` is clamped to [minWaitMs, maxWaitMsBound];
7
+ * defaults allow the 60–180s model supervision cadence and tests may inject
8
+ * short bounds.
9
+ */
10
+ export const DEFAULT_OPERATION_WAIT_MIN_MS = 60_000;
11
+ export const DEFAULT_OPERATION_WAIT_MAX_MS = 180_000;
12
+ export class OperationWaitError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.code = code;
17
+ this.name = "OperationWaitError";
18
+ }
19
+ }
20
+ /**
21
+ * Pure wake classification: heartbeats are liveness telemetry, not progress.
22
+ * Everything else (state/result/stdout/stderr/error/reconcile) is meaningful.
23
+ *
24
+ * Exception (2026-08-15): stderr/stdout events whose payload is a DAG-runner
25
+ * telemetry log line (`[dag execute] heartbeat ...` / `[task advance] heartbeat
26
+ * ...` / bare `heartbeat ...`) are also liveness, not progress. Without this,
27
+ * a long run's periodic stderr heartbeat logs settle every operationWait
28
+ * immediately (55/55 observed instant returns) and force the model back into
29
+ * high-frequency bash polling — defeating the 60-180s backoff contract.
30
+ */
31
+ const RUNNER_TELEMETRY_LOG = /^\s*\[?(?:dag execute|task advance|run-dag)\]?\s*heartbeat\b/i;
32
+ const BARE_HEARTBEAT_LOG = /^\s*heartbeat\b/i;
33
+ export function isMeaningfulOperationEvent(event) {
34
+ if (event.kind === "heartbeat")
35
+ return false;
36
+ if (event.kind === "stderr" || event.kind === "stdout") {
37
+ const message = typeof event.message === "string"
38
+ ? event.message
39
+ : "";
40
+ // Multi-line chunks: only telemetry-only chunks are classified liveness.
41
+ const lines = message.split("\n").filter((line) => line.trim() !== "");
42
+ if (lines.length > 0 && lines.every((line) => RUNNER_TELEMETRY_LOG.test(line) || BARE_HEARTBEAT_LOG.test(line))) {
43
+ return false;
44
+ }
45
+ }
46
+ return true;
47
+ }
48
+ function isFocusedOperation(operation) {
49
+ return (isTerminalOperationState(operation.state) ||
50
+ operation.state === "needs-reconcile");
51
+ }
52
+ function settledSummary(input) {
53
+ const projectedEvents = input.newEvents.length > 0
54
+ ? input.newEvents.map(projectOperationEventSummary)
55
+ : [];
56
+ // Cursor safety: nextSeq always covers every observed seq so heartbeats
57
+ // filtered out of the payload are never replayed on the next round.
58
+ const nextSeq = Math.max(input.afterSeq, input.observedSeq);
59
+ return {
60
+ operationId: input.operationId,
61
+ state: input.operation.state,
62
+ changed: input.changed,
63
+ timedOut: input.timedOut,
64
+ events: projectedEvents,
65
+ nextSeq,
66
+ recommendedNextCall: {
67
+ operationId: input.operationId,
68
+ afterSeq: nextSeq,
69
+ wakeOn: "meaningful",
70
+ },
71
+ operation: projectOperationForChat(input.operation),
72
+ };
73
+ }
74
+ /**
75
+ * Deterministic read-only wait over the canonical operation event stream
76
+ * (AC-003 / AC-004). Completion paths (wakeOn=meaningful default):
77
+ * - existing meaningful events (seq > afterSeq) → immediate (changed: true);
78
+ * - operation terminal/needs-reconcile → immediate flush of meaningful events;
79
+ * - first subscribed meaningful event → immediate settle;
80
+ * - maxWaitMs elapsed without meaningful change → timedOut summary whose
81
+ * nextSeq still covers every observed (heartbeat) seq.
82
+ *
83
+ * wakeOn=all preserves the legacy diagnostic semantics: any first event,
84
+ * heartbeat included, settles and is returned.
85
+ *
86
+ * Race safety: the listener is registered BEFORE listFrom, closing the
87
+ * listFrom/subscribe gap; every path settles exactly once through a guarded
88
+ * `finish`, and listener + timer are always cleaned up on settle.
89
+ */
90
+ export async function waitForOperationChange(input) {
91
+ const operationId = input.operationId?.trim();
92
+ if (!operationId) {
93
+ throw new OperationWaitError("INVALID_INPUT", "operationId is required");
94
+ }
95
+ if (!Number.isInteger(input.afterSeq) || input.afterSeq < 0) {
96
+ throw new OperationWaitError("INVALID_INPUT", "afterSeq must be a non-negative integer");
97
+ }
98
+ const wakeOn = input.wakeOn ?? "meaningful";
99
+ if (wakeOn !== "meaningful" && wakeOn !== "all") {
100
+ throw new OperationWaitError("INVALID_INPUT", "wakeOn must be 'meaningful' or 'all'");
101
+ }
102
+ const minWaitMs = input.minWaitMs ?? DEFAULT_OPERATION_WAIT_MIN_MS;
103
+ const maxWaitMsBound = input.maxWaitMsBound ?? DEFAULT_OPERATION_WAIT_MAX_MS;
104
+ if (!Number.isFinite(minWaitMs) ||
105
+ !Number.isFinite(maxWaitMsBound) ||
106
+ minWaitMs < 0 ||
107
+ maxWaitMsBound < minWaitMs) {
108
+ throw new OperationWaitError("INVALID_INPUT", "invalid wait bounds");
109
+ }
110
+ const rawMaxWaitMs = input.maxWaitMs ?? maxWaitMsBound;
111
+ if (!Number.isFinite(rawMaxWaitMs) || rawMaxWaitMs < 0) {
112
+ throw new OperationWaitError("INVALID_INPUT", "maxWaitMs must be a non-negative number");
113
+ }
114
+ const maxWaitMs = Math.max(minWaitMs, Math.min(maxWaitMsBound, Math.floor(rawMaxWaitMs)));
115
+ const afterSeq = input.afterSeq;
116
+ /** Wake filter per policy: meaningful drops heartbeat payload entirely. */
117
+ const wakeFilter = (events) => wakeOn === "meaningful"
118
+ ? events.filter((event) => isMeaningfulOperationEvent(event))
119
+ : events;
120
+ const operation = await input.operations.get(operationId);
121
+ if (!operation) {
122
+ throw new OperationWaitError("NOT_FOUND", `operation not found: ${operationId}`);
123
+ }
124
+ const events = input.events;
125
+ if (isFocusedOperation(operation)) {
126
+ // Flush unconsumed events before the terminal summary: the caller's
127
+ // cursor must advance past every retained canonical event (observed
128
+ // seq includes heartbeats even when their payload is filtered out).
129
+ const listed = events.listFrom(operationId, afterSeq);
130
+ if ("error" in listed) {
131
+ throw new OperationWaitError("EVENT_CURSOR_EXPIRED", "event cursor expired; re-read operation snapshot");
132
+ }
133
+ const wake = wakeFilter(listed.events);
134
+ const observedSeq = listed.events.length > 0
135
+ ? listed.events[listed.events.length - 1].seq
136
+ : afterSeq;
137
+ if (wake.length > 0) {
138
+ return settledSummary({
139
+ operationId,
140
+ operation,
141
+ afterSeq,
142
+ changed: true,
143
+ timedOut: false,
144
+ newEvents: wake,
145
+ observedSeq,
146
+ });
147
+ }
148
+ return settledSummary({
149
+ operationId,
150
+ operation,
151
+ afterSeq,
152
+ changed: false,
153
+ timedOut: false,
154
+ newEvents: [],
155
+ observedSeq,
156
+ });
157
+ }
158
+ return new Promise((resolve, reject) => {
159
+ let settled = false;
160
+ let timer;
161
+ let unsubscribe;
162
+ /** Highest seq observed via listener/listFrom during this wait. */
163
+ let observedSeq = afterSeq;
164
+ const cleanup = () => {
165
+ if (timer !== undefined) {
166
+ clearTimeout(timer);
167
+ timer = undefined;
168
+ }
169
+ if (unsubscribe) {
170
+ unsubscribe();
171
+ unsubscribe = undefined;
172
+ }
173
+ };
174
+ const finish = (result) => {
175
+ if (settled)
176
+ return;
177
+ settled = true;
178
+ cleanup();
179
+ if (result instanceof OperationWaitError)
180
+ reject(result);
181
+ else
182
+ resolve(result);
183
+ };
184
+ const settleWithEvent = (event) => {
185
+ // Future-cursor guard: events at or below the caller's afterSeq are
186
+ // already consumed and must not settle this wait.
187
+ if (event.seq <= afterSeq)
188
+ return;
189
+ // Heartbeats only advance the observed cursor in meaningful mode.
190
+ if (wakeOn === "meaningful" && !isMeaningfulOperationEvent(event)) {
191
+ observedSeq = Math.max(observedSeq, event.seq);
192
+ return;
193
+ }
194
+ observedSeq = Math.max(observedSeq, event.seq);
195
+ // Listener path: async re-read of the operation for a fresh summary.
196
+ void (async () => {
197
+ try {
198
+ const current = await input.operations.get(operationId);
199
+ finish(settledSummary({
200
+ operationId,
201
+ operation: current ?? operation,
202
+ afterSeq,
203
+ changed: true,
204
+ timedOut: false,
205
+ newEvents: [event],
206
+ observedSeq,
207
+ }));
208
+ }
209
+ catch (error) {
210
+ finish(error instanceof OperationWaitError
211
+ ? error
212
+ : new OperationWaitError("INVALID_INPUT", error instanceof Error ? error.message : String(error)));
213
+ }
214
+ })();
215
+ };
216
+ // Terminal recheck before subscribing: events landing during this await
217
+ // are still in the ring and are caught by listFrom below.
218
+ void (async () => {
219
+ try {
220
+ const current = await input.operations.get(operationId);
221
+ if (current && isFocusedOperation(current)) {
222
+ // Same terminal flush as the initial path: events landing during
223
+ // the await are still in the retained ring.
224
+ const listed = events.listFrom(operationId, afterSeq);
225
+ if ("error" in listed) {
226
+ finish(new OperationWaitError("EVENT_CURSOR_EXPIRED", "event cursor expired; re-read operation snapshot"));
227
+ return;
228
+ }
229
+ const wake = wakeFilter(listed.events);
230
+ observedSeq = Math.max(observedSeq, listed.events.length > 0
231
+ ? listed.events[listed.events.length - 1].seq
232
+ : afterSeq);
233
+ if (wake.length > 0) {
234
+ finish(settledSummary({
235
+ operationId,
236
+ operation: current,
237
+ afterSeq,
238
+ changed: true,
239
+ timedOut: false,
240
+ newEvents: wake,
241
+ observedSeq,
242
+ }));
243
+ return;
244
+ }
245
+ finish(settledSummary({
246
+ operationId,
247
+ operation: current,
248
+ afterSeq,
249
+ changed: false,
250
+ timedOut: false,
251
+ newEvents: [],
252
+ observedSeq,
253
+ }));
254
+ return;
255
+ }
256
+ // Subscribe BEFORE listFrom: any event appended after this point
257
+ // reaches the listener, closing the listFrom/subscribe race.
258
+ unsubscribe = events.subscribe(operationId, settleWithEvent);
259
+ const listed = events.listFrom(operationId, afterSeq);
260
+ if ("error" in listed) {
261
+ finish(new OperationWaitError("EVENT_CURSOR_EXPIRED", "event cursor expired; re-read operation snapshot"));
262
+ return;
263
+ }
264
+ observedSeq = Math.max(observedSeq, listed.events.length > 0
265
+ ? listed.events[listed.events.length - 1].seq
266
+ : afterSeq);
267
+ const wake = wakeFilter(listed.events);
268
+ if (wake.length > 0) {
269
+ finish(settledSummary({
270
+ operationId,
271
+ operation: current ?? operation,
272
+ afterSeq,
273
+ changed: true,
274
+ timedOut: false,
275
+ newEvents: wake,
276
+ observedSeq,
277
+ }));
278
+ return;
279
+ }
280
+ // No wake events: arm the bounded wait; the listener settles on
281
+ // the first new wake event, the timer settles on timeout. The
282
+ // timeout summary returns observedSeq so heartbeat-only progress
283
+ // advances the cursor without replaying heartbeat payloads.
284
+ timer = setTimeout(() => {
285
+ void (async () => {
286
+ try {
287
+ const latest = await input.operations.get(operationId);
288
+ finish(settledSummary({
289
+ operationId,
290
+ operation: latest ?? operation,
291
+ afterSeq,
292
+ changed: false,
293
+ timedOut: true,
294
+ newEvents: [],
295
+ observedSeq,
296
+ }));
297
+ }
298
+ catch (error) {
299
+ finish(error instanceof OperationWaitError
300
+ ? error
301
+ : new OperationWaitError("INVALID_INPUT", error instanceof Error ? error.message : String(error)));
302
+ }
303
+ })();
304
+ }, maxWaitMs);
305
+ timer.unref?.();
306
+ }
307
+ catch (error) {
308
+ finish(error instanceof OperationWaitError
309
+ ? error
310
+ : new OperationWaitError("INVALID_INPUT", error instanceof Error ? error.message : String(error)));
311
+ }
312
+ })();
313
+ });
314
+ }