@tea-agent/loop-agent 0.35.3 → 0.36.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/AGENTS.md +4 -2
- package/CHANGELOG.md +59 -0
- package/dist/application/task-lifecycle/advance.js +1 -0
- package/dist/application/task-lifecycle/observe.js +15 -0
- package/dist/application/task-lifecycle/plan-transitions.js +15 -0
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init-upgrade.js +351 -19
- package/dist/commands/init.js +14 -67
- package/dist/commands/run-dag-progress.js +14 -0
- package/dist/commands/task-advance.js +33 -3
- package/dist/executors/dag-pi-executor.js +3 -1
- package/dist/shared/operator/capabilities.js +125 -11
- package/dist/task/source-prepare/completeness.js +17 -0
- package/dist/task/source-prepare/parse-intent.js +15 -1
- package/dist/worker/console/chat/artifact-card.js +8 -1
- package/dist/worker/console/chat/chat-event-store.js +61 -0
- package/dist/worker/console/chat/human-gate-card.js +9 -1
- package/dist/worker/console/chat/operation-card.js +71 -2
- package/dist/worker/console/chat/pi-runtime.js +69 -30
- package/dist/worker/console/chat/routes.js +27 -4
- package/dist/worker/console/chat/semantic-activity.js +465 -0
- package/dist/worker/console/chat/turn-process.js +31 -12
- package/dist/worker/console/operation-run-facts.js +190 -0
- package/dist/worker/console/operation-runner.js +107 -6
- package/dist/worker/console/operation-wait.js +314 -0
- package/dist/worker/console/operator-actions.js +153 -2
- package/dist/worker/console/static/assets/index-2OeZODxk.js +57 -0
- package/dist/worker/console/static/assets/index-DVJlUL8X.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/operator-chat/activity-journey.js +125 -0
- package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +73 -0
- package/dist/worker/console/static-src/operator-chat/activity-references.js +20 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +69 -31
- package/dist/worker/console/static-src/operator-chat/slash-palette-layout.js +24 -0
- package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +141 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +2 -1
- package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +59 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -5
- package/dist/worker/console/static-src/operator-chat/useComposer.js +30 -7
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +7 -3
- package/dist/worker/observe/static/operator-chrome.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +74 -2
- package/docs/README.md +3 -3
- package/docs/architecture/evolution.md +102 -67
- package/docs/templates/init-managed-agents.md +5 -2
- package/harness.json +2 -2
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +1 -0
- package/skills/loop-agent/references/command-reference.md +2 -0
- package/dist/worker/console/static/assets/index-DRqZiQ7J.css +0 -1
- 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
|
|
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
|
|
223
|
+
errorCode: ok
|
|
224
|
+
? undefined
|
|
225
|
+
: (businessBlock?.code ?? envelope?.error?.code ?? "INTERNAL_ERROR"),
|
|
142
226
|
errorMessage: ok
|
|
143
227
|
? undefined
|
|
144
|
-
: (
|
|
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: {
|
|
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
|
+
}
|