@tea-agent/loop-agent 0.16.26 → 0.17.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 (103) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +55 -7
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +55 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/shared/operator/capabilities.js +255 -0
  16. package/dist/shared/operator/envelope.js +59 -0
  17. package/dist/shared/operator/index.js +4 -0
  18. package/dist/shared/operator/registry.js +38 -0
  19. package/dist/shared/operator/types.js +5 -0
  20. package/dist/task/contract/adopt.js +166 -0
  21. package/dist/task/contract/apply.js +326 -0
  22. package/dist/task/contract/canonicalize.js +60 -0
  23. package/dist/task/contract/constants.js +29 -0
  24. package/dist/task/contract/diff.js +177 -0
  25. package/dist/task/contract/hash.js +42 -0
  26. package/dist/task/contract/import-revision.js +96 -0
  27. package/dist/task/contract/index.js +17 -0
  28. package/dist/task/contract/journal.js +155 -0
  29. package/dist/task/contract/lock.js +153 -0
  30. package/dist/task/contract/observe.js +296 -0
  31. package/dist/task/contract/paths.js +19 -0
  32. package/dist/task/contract/project.js +170 -0
  33. package/dist/task/contract/recover.js +312 -0
  34. package/dist/task/contract/request-ledger.js +37 -0
  35. package/dist/task/contract/schema.js +151 -0
  36. package/dist/task/contract/transaction.js +160 -0
  37. package/dist/task/contract/types.js +1 -0
  38. package/dist/task/contract/validate-draft.js +106 -0
  39. package/dist/task/index.js +3 -0
  40. package/dist/task/operator/capabilities.js +6 -0
  41. package/dist/task/operator/envelope.js +2 -0
  42. package/dist/task/operator/index.js +5 -0
  43. package/dist/task/operator/registry.js +2 -0
  44. package/dist/task/operator/types.js +1 -0
  45. package/dist/task/runtime.js +5 -1
  46. package/dist/task/source-references.js +7 -0
  47. package/dist/worker/cli.js +150 -32
  48. package/dist/worker/console/app-data.js +185 -0
  49. package/dist/worker/console/dag-confirmation.js +313 -0
  50. package/dist/worker/console/doctor.js +169 -0
  51. package/dist/worker/console/draft-store.js +80 -0
  52. package/dist/worker/console/index.js +15 -0
  53. package/dist/worker/console/interview/assessment.js +67 -0
  54. package/dist/worker/console/interview/session.js +100 -0
  55. package/dist/worker/console/interview/tools.js +109 -0
  56. package/dist/worker/console/loopback.js +16 -0
  57. package/dist/worker/console/observe-health-match.js +174 -0
  58. package/dist/worker/console/observe-link.js +33 -0
  59. package/dist/worker/console/operation-runner.js +166 -0
  60. package/dist/worker/console/operation-sse.js +158 -0
  61. package/dist/worker/console/operation-store.js +147 -0
  62. package/dist/worker/console/operator-actions.js +769 -0
  63. package/dist/worker/console/pi-readiness.js +94 -0
  64. package/dist/worker/console/recovery-cta.js +133 -0
  65. package/dist/worker/console/repo-fingerprint.js +29 -0
  66. package/dist/worker/console/resource-loader.js +95 -0
  67. package/dist/worker/console/routes.js +368 -0
  68. package/dist/worker/console/security.js +126 -0
  69. package/dist/worker/console/server.js +149 -0
  70. package/dist/worker/console/sibling-controller.js +28 -0
  71. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  72. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  73. package/dist/worker/console/static/index.html +13 -0
  74. package/dist/worker/console/vite.config.js +27 -0
  75. package/dist/worker/observe/health.js +57 -0
  76. package/dist/worker/observe/routes.js +16 -9
  77. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  78. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  79. package/dist/workflows/dag/frontend-repair.js +1 -10
  80. package/dist/workflows/dag/init-hybrid.js +269 -116
  81. package/dist/workflows/dag/node-execution.js +25 -1
  82. package/dist/workflows/dag/retry-policy.js +16 -1
  83. package/dist/workflows/dag/runner.js +9 -0
  84. package/dist/workflows/dag/task-contract-binding.js +138 -0
  85. package/dist/workflows/dag/types.js +74 -10
  86. package/dist/workflows/dag/validate.js +25 -7
  87. package/docs/README.md +2 -0
  88. package/docs/architecture/evolution.md +2 -0
  89. package/docs/architecture/system-overview.md +6 -0
  90. package/docs/architecture/worker-and-feature.md +7 -0
  91. package/docs/templates/agent-dag.schema.json +49 -2
  92. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  93. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  94. package/docs/templates/backend-test-dag.json +26 -154
  95. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  96. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  97. package/package.json +8 -2
  98. package/skills/agent-worker/SKILL.md +1 -0
  99. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  100. package/skills/frontend-design-review/SKILL.md +25 -16
  101. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  102. package/skills/loop-agent/references/command-reference.md +48 -1
  103. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -0,0 +1,100 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import path from "node:path";
3
+ import { readJsonIfExists, writeSecureJson, } from "../app-data.js";
4
+ import { createInterviewResourceLoader, } from "./tools.js";
5
+ function sessionPath(appData, sessionId) {
6
+ return path.join(appData.interviews, `${sessionId}.json`);
7
+ }
8
+ export function newInterviewSessionId() {
9
+ return `iv_${randomBytes(12).toString("hex")}`;
10
+ }
11
+ export class InterviewSessionStore {
12
+ appData;
13
+ loader;
14
+ constructor(appData) {
15
+ this.appData = appData;
16
+ this.loader = createInterviewResourceLoader();
17
+ }
18
+ async get(sessionId) {
19
+ return readJsonIfExists(sessionPath(this.appData, sessionId));
20
+ }
21
+ async create(input) {
22
+ const now = new Date().toISOString();
23
+ const session = {
24
+ schemaVersion: 1,
25
+ sessionId: newInterviewSessionId(),
26
+ taskId: input.taskId,
27
+ state: "not-started",
28
+ operatorSessionId: input.operatorSessionId,
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ answers: [],
32
+ instructionVersion: input.instructionVersion ?? "grill-me@1",
33
+ };
34
+ await writeSecureJson(sessionPath(this.appData, session.sessionId), session);
35
+ return session;
36
+ }
37
+ async save(session) {
38
+ const next = { ...session, updatedAt: new Date().toISOString() };
39
+ await writeSecureJson(sessionPath(this.appData, next.sessionId), next);
40
+ return next;
41
+ }
42
+ /**
43
+ * grill-me: open exactly one question (or clear when complete).
44
+ */
45
+ async setCurrentQuestion(sessionId, question, state = "eliciting") {
46
+ const session = await this.get(sessionId);
47
+ if (!session)
48
+ throw new Error(`interview session not found: ${sessionId}`);
49
+ return this.save({
50
+ ...session,
51
+ currentQuestion: question,
52
+ state: question ? state : session.state,
53
+ });
54
+ }
55
+ async recordAnswer(sessionId, answer) {
56
+ const session = await this.get(sessionId);
57
+ if (!session)
58
+ throw new Error(`interview session not found: ${sessionId}`);
59
+ if (session.currentQuestion &&
60
+ session.currentQuestion.id !== answer.questionId) {
61
+ throw new Error("answer does not match current open question");
62
+ }
63
+ return this.save({
64
+ ...session,
65
+ answers: [...session.answers, answer],
66
+ currentQuestion: undefined,
67
+ state: "awaiting-user-answer",
68
+ });
69
+ }
70
+ async markDraftReady(sessionId, draft, draftSha256) {
71
+ const session = await this.get(sessionId);
72
+ if (!session)
73
+ throw new Error(`interview session not found: ${sessionId}`);
74
+ if (session.taskId !== draft.taskId) {
75
+ throw new Error("draft taskId does not match interview session");
76
+ }
77
+ return this.save({
78
+ ...session,
79
+ state: "draft-ready",
80
+ draftSha256,
81
+ currentQuestion: undefined,
82
+ });
83
+ }
84
+ async markAwaitingConfirmation(sessionId, assessmentId) {
85
+ const session = await this.get(sessionId);
86
+ if (!session)
87
+ throw new Error(`interview session not found: ${sessionId}`);
88
+ return this.save({
89
+ ...session,
90
+ state: "awaiting-confirmation",
91
+ assessmentId,
92
+ });
93
+ }
94
+ async markApplied(sessionId) {
95
+ const session = await this.get(sessionId);
96
+ if (!session)
97
+ throw new Error(`interview session not found: ${sessionId}`);
98
+ return this.save({ ...session, state: "applied" });
99
+ }
100
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Requirement Interview closed tool registry (design §7.5).
3
+ * Server-side enforcement only — UI hiding is not enough.
4
+ */
5
+ export const INTERVIEW_ALLOWED_TOOLS = Object.freeze([
6
+ "draft_read",
7
+ "draft_update",
8
+ "contract_validate",
9
+ "contract_diff",
10
+ "repo_explore_readonly",
11
+ "reference_read",
12
+ ]);
13
+ /** Explicit denials for mutation / coding tools (AC-C2-02). */
14
+ export const INTERVIEW_DENIED_TOOLS = Object.freeze([
15
+ "edit",
16
+ "write",
17
+ "bash",
18
+ "shell",
19
+ "full-tools",
20
+ "full_tools",
21
+ "apply_patch",
22
+ "apply-patch",
23
+ "contractApply",
24
+ "contract_apply",
25
+ "runDag",
26
+ "run_dag",
27
+ "reconcile",
28
+ "dagAbandon",
29
+ "dag_approve",
30
+ "dagApprove",
31
+ "dagReject",
32
+ "dagResume",
33
+ "importPrd",
34
+ "newTask",
35
+ "contractAdopt",
36
+ ]);
37
+ const ALLOWED = new Set(INTERVIEW_ALLOWED_TOOLS.map((t) => t.toLowerCase()));
38
+ const DENIED = new Set(INTERVIEW_DENIED_TOOLS.map((t) => t.toLowerCase()));
39
+ export function isInterviewToolAllowed(toolId) {
40
+ return ALLOWED.has(toolId.trim().toLowerCase());
41
+ }
42
+ export function isInterviewToolDenied(toolId) {
43
+ const id = toolId.trim().toLowerCase();
44
+ if (DENIED.has(id))
45
+ return true;
46
+ // normalize snake/camel: contractApply already listed; catch run-dag variants
47
+ const compact = id.replace(/[-_]/g, "");
48
+ for (const d of DENIED) {
49
+ if (d.replace(/[-_]/g, "") === compact)
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ /**
55
+ * Gate 1/2/3: session create, setActiveTools, and each tool call.
56
+ */
57
+ export function authorizeInterviewTool(toolId) {
58
+ const id = toolId.trim();
59
+ if (!id) {
60
+ return {
61
+ ok: false,
62
+ code: "tool-denied",
63
+ toolId: id,
64
+ message: "empty tool id is not allowed in Requirement Interview",
65
+ };
66
+ }
67
+ if (isInterviewToolDenied(id) || !isInterviewToolAllowed(id)) {
68
+ return {
69
+ ok: false,
70
+ code: "tool-denied",
71
+ toolId: id,
72
+ message: `tool "${id}" is not permitted in Requirement Interview (no contractApply/runDag/reconcile/edit/write/bash)`,
73
+ };
74
+ }
75
+ return { ok: true, toolId: id };
76
+ }
77
+ export function filterActiveInterviewTools(requested) {
78
+ const allowed = [];
79
+ const denied = [];
80
+ for (const toolId of requested) {
81
+ const decision = authorizeInterviewTool(toolId);
82
+ if (decision.ok)
83
+ allowed.push(decision.toolId);
84
+ else
85
+ denied.push(toolId);
86
+ }
87
+ return { allowed, denied };
88
+ }
89
+ export function createInterviewResourceLoader() {
90
+ let active = new Set();
91
+ return {
92
+ mode: "requirement-interview",
93
+ allowedToolIds: INTERVIEW_ALLOWED_TOOLS,
94
+ deniedToolIds: INTERVIEW_DENIED_TOOLS,
95
+ listTools: () => INTERVIEW_ALLOWED_TOOLS,
96
+ tryActivateTool: (toolId) => {
97
+ const decision = authorizeInterviewTool(toolId);
98
+ if (decision.ok)
99
+ active.add(decision.toolId);
100
+ return decision;
101
+ },
102
+ setActiveToolsByName: (names) => {
103
+ const { allowed, denied } = filterActiveInterviewTools(names);
104
+ active = new Set(allowed);
105
+ return { active: [...active], denied };
106
+ },
107
+ allowsEnvToolRestore: false,
108
+ };
109
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Console-local loopback host check. Non-loopback binds fail closed
3
+ * (unlike Observe, which only warns).
4
+ */
5
+ export function isLoopbackHost(host) {
6
+ const normalized = host.trim().toLowerCase();
7
+ if (!normalized)
8
+ return false;
9
+ if (normalized === "localhost" ||
10
+ normalized === "::1" ||
11
+ normalized === "[::1]") {
12
+ return true;
13
+ }
14
+ // 127.0.0.0/8
15
+ return /^127(?:\.\d{1,3}){3}$/.test(normalized);
16
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Observe health match classification (Phase 3).
3
+ * Fail-closed deep links: only same-repo + schema + required capability.
4
+ */
5
+ export const OBSERVE_START_COMMAND = "agent-worker observe serve --repo . --port 8787";
6
+ export const DEFAULT_OBSERVE_BASE_URL = "http://127.0.0.1:8787";
7
+ export function capabilityForObserveTarget(target) {
8
+ switch (target.kind) {
9
+ case "dag":
10
+ return "dagRun";
11
+ case "task":
12
+ return "task";
13
+ case "feature":
14
+ return "feature";
15
+ case "worker":
16
+ return "workerRun";
17
+ case "batch":
18
+ return "batch";
19
+ default: {
20
+ const _exhaustive = target;
21
+ throw new Error(`capabilityForObserveTarget: unsupported ${JSON.stringify(_exhaustive)}`);
22
+ }
23
+ }
24
+ }
25
+ /**
26
+ * Classify Observe /api/health body against local repo fingerprint + required capability.
27
+ * Pure function — no I/O.
28
+ */
29
+ export function classifyObserveHealth(input) {
30
+ const url = input.healthUrl ?? `${DEFAULT_OBSERVE_BASE_URL}/api/health`;
31
+ const startCommand = OBSERVE_START_COMMAND;
32
+ const body = input.healthBody;
33
+ if (body === null || body === undefined || typeof body !== "object") {
34
+ return {
35
+ status: "unavailable",
36
+ url,
37
+ message: "Observe health body missing or not JSON object",
38
+ startCommand,
39
+ body,
40
+ localFingerprint: input.localFingerprint,
41
+ requiredCapability: input.requiredCapability,
42
+ };
43
+ }
44
+ const health = body;
45
+ if (health.ok !== true) {
46
+ return {
47
+ status: "unavailable",
48
+ url,
49
+ message: "Observe health ok !== true",
50
+ startCommand,
51
+ body,
52
+ localFingerprint: input.localFingerprint,
53
+ requiredCapability: input.requiredCapability,
54
+ };
55
+ }
56
+ const schemaVersion = typeof health.schemaVersion === "number" ? health.schemaVersion : undefined;
57
+ if (schemaVersion !== 1) {
58
+ return {
59
+ status: "mismatch",
60
+ url,
61
+ message: `Observe schemaVersion mismatch (expected 1, got ${String(health.schemaVersion)})`,
62
+ startCommand,
63
+ body,
64
+ localFingerprint: input.localFingerprint,
65
+ schemaVersion,
66
+ requiredCapability: input.requiredCapability,
67
+ };
68
+ }
69
+ const remoteFingerprint = typeof health.repoFingerprint === "string"
70
+ ? health.repoFingerprint
71
+ : undefined;
72
+ if (!remoteFingerprint || remoteFingerprint !== input.localFingerprint) {
73
+ return {
74
+ status: "mismatch",
75
+ url,
76
+ message: "Observe repoFingerprint does not match local worktree",
77
+ startCommand,
78
+ body,
79
+ localFingerprint: input.localFingerprint,
80
+ remoteFingerprint,
81
+ schemaVersion,
82
+ requiredCapability: input.requiredCapability,
83
+ };
84
+ }
85
+ if (input.requiredCapability) {
86
+ const caps = health.routeCapabilities ?? {};
87
+ const capabilityOk = caps[input.requiredCapability] === true;
88
+ if (!capabilityOk) {
89
+ return {
90
+ status: "mismatch",
91
+ url,
92
+ message: `Observe missing route capability: ${input.requiredCapability}`,
93
+ startCommand,
94
+ body,
95
+ localFingerprint: input.localFingerprint,
96
+ remoteFingerprint,
97
+ schemaVersion,
98
+ requiredCapability: input.requiredCapability,
99
+ capabilityOk: false,
100
+ };
101
+ }
102
+ return {
103
+ status: "match",
104
+ url,
105
+ message: "Observe health matches local repo and required capability",
106
+ startCommand,
107
+ body,
108
+ localFingerprint: input.localFingerprint,
109
+ remoteFingerprint,
110
+ schemaVersion,
111
+ requiredCapability: input.requiredCapability,
112
+ capabilityOk: true,
113
+ };
114
+ }
115
+ return {
116
+ status: "match",
117
+ url,
118
+ message: "Observe health matches local repo",
119
+ startCommand,
120
+ body,
121
+ localFingerprint: input.localFingerprint,
122
+ remoteFingerprint,
123
+ schemaVersion,
124
+ };
125
+ }
126
+ /**
127
+ * Probe Observe /api/health and classify against local fingerprint.
128
+ * Network failure → unavailable (non-blocking for Console doctor).
129
+ */
130
+ export async function probeAndClassifyObserve(input) {
131
+ const base = input.baseUrl.replace(/\/+$/, "");
132
+ const healthUrl = `${base}/api/health`;
133
+ const fetchImpl = input.fetchImpl ?? fetch;
134
+ const timeoutMs = input.timeoutMs ?? 1500;
135
+ try {
136
+ const response = await fetchImpl(healthUrl, {
137
+ method: "GET",
138
+ signal: AbortSignal.timeout(timeoutMs),
139
+ });
140
+ if (!response.ok) {
141
+ return {
142
+ status: "unavailable",
143
+ url: healthUrl,
144
+ message: `HTTP ${response.status}`,
145
+ startCommand: OBSERVE_START_COMMAND,
146
+ localFingerprint: input.localFingerprint,
147
+ requiredCapability: input.requiredCapability,
148
+ };
149
+ }
150
+ let body;
151
+ try {
152
+ body = await response.json();
153
+ }
154
+ catch {
155
+ body = undefined;
156
+ }
157
+ return classifyObserveHealth({
158
+ localFingerprint: input.localFingerprint,
159
+ healthBody: body,
160
+ requiredCapability: input.requiredCapability,
161
+ healthUrl,
162
+ });
163
+ }
164
+ catch (error) {
165
+ return {
166
+ status: "unavailable",
167
+ url: healthUrl,
168
+ message: error instanceof Error ? error.message : String(error),
169
+ startCommand: OBSERVE_START_COMMAND,
170
+ localFingerprint: input.localFingerprint,
171
+ requiredCapability: input.requiredCapability,
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Observe hash-route deep-link builder (Phase 1 skeleton).
3
+ * Does not mount/proxy Observe or fetch its snapshot.
4
+ */
5
+ function stripTrailingSlash(baseUrl) {
6
+ return baseUrl.replace(/\/+$/, "");
7
+ }
8
+ /**
9
+ * Build a browser URL into an independent Observe instance.
10
+ * Shape mirrors Observe hash routes (`#/dag/...`, `#/task/...`, …).
11
+ */
12
+ export function buildObserveDeepLink(baseUrl, target) {
13
+ const base = stripTrailingSlash(baseUrl.trim());
14
+ if (!base) {
15
+ throw new Error("buildObserveDeepLink: baseUrl is required");
16
+ }
17
+ switch (target.kind) {
18
+ case "dag":
19
+ return `${base}/#/dag/${encodeURIComponent(target.dagRunId)}`;
20
+ case "task":
21
+ return `${base}/#/task/${encodeURIComponent(target.taskId)}`;
22
+ case "feature":
23
+ return `${base}/#/feature/${encodeURIComponent(target.featureId)}`;
24
+ case "worker":
25
+ return `${base}/#/run/${encodeURIComponent(target.workerRunId)}`;
26
+ case "batch":
27
+ return `${base}/#/batch/${encodeURIComponent(target.batchRunId)}`;
28
+ default: {
29
+ const _exhaustive = target;
30
+ throw new Error(`buildObserveDeepLink: unsupported target ${JSON.stringify(_exhaustive)}`);
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,166 @@
1
+ import { clipPreview, } from "./operation-store.js";
2
+ import { stateEvent } from "./operation-sse.js";
3
+ /**
4
+ * Execute a queued operation via LoopAgentClient (or inject).
5
+ * Timeout without proven exit → needs-reconcile (never Cancel).
6
+ */
7
+ export async function runOperation(operationId, deps) {
8
+ const op = await deps.store.get(operationId);
9
+ if (!op) {
10
+ throw new Error(`operation not found: ${operationId}`);
11
+ }
12
+ if (op.state !== "queued" && op.state !== "needs-reconcile") {
13
+ return op;
14
+ }
15
+ const args = op.cliArgs;
16
+ if (!args || args.length === 0) {
17
+ const failed = await deps.store.update(operationId, {
18
+ state: "failed",
19
+ finishedAt: new Date().toISOString(),
20
+ errorCode: "INVALID_INPUT",
21
+ errorMessage: "operation has no cliArgs to execute",
22
+ });
23
+ stateEvent(deps.events, failed, "failed", failed.errorMessage);
24
+ return failed;
25
+ }
26
+ await deps.store.update(operationId, {
27
+ state: "starting",
28
+ startedAt: new Date().toISOString(),
29
+ controllerIdentity: deps.client.getIdentity?.(),
30
+ });
31
+ stateEvent(deps.events, op, "starting", "spawning sibling loop-agent");
32
+ const run = deps.runCommand ??
33
+ ((cliArgs, options) => deps.client.run(cliArgs, options));
34
+ let finished;
35
+ try {
36
+ const result = await run(args, {
37
+ cwd: deps.repoRoot,
38
+ artifactName: `operation-${operationId}`,
39
+ expectJson: true,
40
+ timeoutMs: deps.defaultTimeoutMs,
41
+ onSpawn: (info) => {
42
+ void deps.store.update(operationId, {
43
+ state: "running",
44
+ pid: info.pid,
45
+ processStartIdentity: info.startedAt,
46
+ });
47
+ stateEvent(deps.events, op, "running", `pid=${info.pid ?? "?"}`);
48
+ },
49
+ onStdout: (chunk) => {
50
+ deps.events.append(operationId, {
51
+ at: new Date().toISOString(),
52
+ kind: "stdout",
53
+ message: chunk,
54
+ });
55
+ },
56
+ onStderr: (chunk) => {
57
+ deps.events.append(operationId, {
58
+ at: new Date().toISOString(),
59
+ kind: "stderr",
60
+ message: chunk,
61
+ });
62
+ },
63
+ });
64
+ finished = await finalizeFromWorkerResult(operationId, result, deps);
65
+ }
66
+ catch (error) {
67
+ // Cannot prove CLI exit classification → needs-reconcile (not Cancel)
68
+ finished = await deps.store.update(operationId, {
69
+ state: "needs-reconcile",
70
+ finishedAt: new Date().toISOString(),
71
+ errorCode: "OPERATION_NEEDS_RECONCILE",
72
+ errorMessage: error instanceof Error ? error.message : String(error),
73
+ });
74
+ stateEvent(deps.events, finished, "needs-reconcile", finished.errorMessage);
75
+ deps.events.append(operationId, {
76
+ at: new Date().toISOString(),
77
+ kind: "error",
78
+ message: finished.errorMessage,
79
+ state: "needs-reconcile",
80
+ });
81
+ }
82
+ return finished;
83
+ }
84
+ async function finalizeFromWorkerResult(operationId, result, deps) {
85
+ const previewStdout = clipPreview(result.stdout ?? "");
86
+ const previewStderr = clipPreview(result.stderr ?? "");
87
+ const artifactRefs = {
88
+ stdoutPath: result.artifacts.stdoutPath,
89
+ stderrPath: result.artifacts.stderrPath,
90
+ resultPath: result.artifacts.resultPath,
91
+ dir: result.artifacts.dir,
92
+ };
93
+ if (result.timedOut) {
94
+ // Supervisory timeout without classified CLI timeout → needs-reconcile
95
+ // Only mark timed-out when we have a machine timeout result after exit.
96
+ const state = result.exitCode !== null ? "timed-out" : "needs-reconcile";
97
+ const updated = await deps.store.update(operationId, {
98
+ state,
99
+ finishedAt: new Date().toISOString(),
100
+ artifactRefs,
101
+ stdoutPreview: previewStdout,
102
+ stderrPreview: previewStderr,
103
+ errorCode: state === "timed-out" ? "INTERNAL_ERROR" : "OPERATION_NEEDS_RECONCILE",
104
+ errorMessage: state === "timed-out"
105
+ ? "CLI timed out with process exit"
106
+ : "timeout reached without proven process exit; needs reconcile (not cancelled)",
107
+ result: coerceEnvelope(result),
108
+ });
109
+ stateEvent(deps.events, updated, state, updated.errorMessage);
110
+ return updated;
111
+ }
112
+ const envelope = coerceEnvelope(result);
113
+ const ok = envelope?.ok === true || (result.ok && result.exitCode === 0);
114
+ const state = ok ? "succeeded" : "failed";
115
+ const updated = await deps.store.update(operationId, {
116
+ state,
117
+ finishedAt: new Date().toISOString(),
118
+ artifactRefs,
119
+ stdoutPreview: previewStdout,
120
+ stderrPreview: previewStderr,
121
+ result: envelope ?? {
122
+ exitCode: result.exitCode,
123
+ ok: result.ok,
124
+ stdout: previewStdout,
125
+ stderr: previewStderr,
126
+ },
127
+ errorCode: ok ? undefined : (envelope?.error?.code ?? "INTERNAL_ERROR"),
128
+ errorMessage: ok
129
+ ? undefined
130
+ : (envelope?.error?.message ??
131
+ `command failed with exit ${result.exitCode}`),
132
+ });
133
+ stateEvent(deps.events, updated, state);
134
+ deps.events.append(operationId, {
135
+ at: new Date().toISOString(),
136
+ kind: "result",
137
+ state,
138
+ data: { ok, exitCode: result.exitCode },
139
+ });
140
+ return updated;
141
+ }
142
+ function coerceEnvelope(result) {
143
+ const json = result.json;
144
+ if (!json || typeof json !== "object")
145
+ return undefined;
146
+ const rec = json;
147
+ if (rec.schemaVersion === 1 && typeof rec.ok === "boolean") {
148
+ return json;
149
+ }
150
+ return undefined;
151
+ }
152
+ /** Fire-and-forget scheduler for accepted operations. */
153
+ export function scheduleOperation(operationId, deps) {
154
+ void runOperation(operationId, deps).catch((error) => {
155
+ void deps.store
156
+ .update(operationId, {
157
+ state: "needs-reconcile",
158
+ finishedAt: new Date().toISOString(),
159
+ errorCode: "OPERATION_NEEDS_RECONCILE",
160
+ errorMessage: error instanceof Error ? error.message : String(error),
161
+ })
162
+ .catch(() => {
163
+ // ignore secondary failures
164
+ });
165
+ });
166
+ }