@awak-app/simy-cli 0.6.10 → 0.6.12

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.
@@ -3,6 +3,8 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
 
6
+ import { assertConnectionScope, connectionDiagnostic } from "./connection-diagnostics.js";
7
+
6
8
  import { normalizeWebOrigin } from "./web-origin.js";
7
9
 
8
10
  export function connectionDiscoveryPath(webOrigin, sessionRoot) {
@@ -47,3 +49,162 @@ export async function removeConnectionDiscovery(discovery) {
47
49
  if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
48
50
  }
49
51
  }
52
+
53
+ // A private discovery file identifies a candidate; the live instance must prove
54
+ // it owns that record before an entry point can reuse it. Never send a token.
55
+ export async function findReusableConnection({
56
+ webOrigin,
57
+ sessionRoot,
58
+ cliVersion,
59
+ apiContractVersion,
60
+ expectedScope,
61
+ requestedPort = 0,
62
+ authorize = false,
63
+ reauthorize = false,
64
+ fetchImpl = fetch,
65
+ alive = (pid) => {
66
+ try {
67
+ process.kill(pid, 0);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ },
73
+ }) {
74
+ const origin = normalizeWebOrigin(webOrigin);
75
+ let record;
76
+ try {
77
+ record = JSON.parse(
78
+ await readFile(connectionDiscoveryPath(origin, sessionRoot), "utf8"),
79
+ );
80
+ } catch (error) {
81
+ if (error.code === "ENOENT") return null;
82
+ throw connectionError("connection_service_mismatch");
83
+ }
84
+ if (
85
+ record?.schema_version !== 1 ||
86
+ record.web_origin !== origin ||
87
+ !Number.isInteger(record.port) ||
88
+ record.port < 1 ||
89
+ record.port > 65535 ||
90
+ !Number.isInteger(record.pid) ||
91
+ record.pid <= 0 ||
92
+ typeof record.instance_id !== "string" ||
93
+ !record.instance_id
94
+ ) {
95
+ throw connectionError("connection_service_mismatch");
96
+ }
97
+ if (!alive(record.pid)) return null;
98
+ let response, health;
99
+ try {
100
+ response = await fetchImpl(
101
+ `http://127.0.0.1:${record.port}/v1/connection`,
102
+ {
103
+ signal: AbortSignal.timeout(1500),
104
+ redirect: "error",
105
+ cache: "no-store",
106
+ },
107
+ );
108
+ health = response.ok ? await response.json() : null;
109
+ } catch {
110
+ throw connectionError("connection_service_unavailable");
111
+ }
112
+ if (response.status === 404)
113
+ throw connectionError("connection_service_incompatible");
114
+ if (
115
+ !health?.ok ||
116
+ health.instance_id !== record.instance_id ||
117
+ health.pid !== record.pid ||
118
+ health.web_origin !== origin
119
+ ) {
120
+ throw connectionError("connection_service_mismatch");
121
+ }
122
+ if (health.daemon !== true)
123
+ throw connectionError("connection_service_not_background");
124
+ if (requestedPort && requestedPort !== record.port)
125
+ throw connectionError("connection_port_mismatch");
126
+ if (
127
+ !compatibleServiceVersion(health.cli_version, cliVersion) ||
128
+ health.api_contract_version !== apiContractVersion
129
+ )
130
+ throw connectionError("connection_service_incompatible");
131
+ assertConnectionScope(expectedScope, health.scope);
132
+ if (authorize && (reauthorize || !health.session_valid)) {
133
+ try {
134
+ response = await fetchImpl(
135
+ `http://127.0.0.1:${record.port}/v1/connection`,
136
+ {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json" },
139
+ body: JSON.stringify({
140
+ instance_id: record.instance_id,
141
+ scope: health.scope,
142
+ reauthorize,
143
+ }),
144
+ signal: AbortSignal.timeout(1500),
145
+ redirect: "error",
146
+ cache: "no-store",
147
+ },
148
+ );
149
+ health = await response.json().catch(() => null);
150
+ } catch {
151
+ throw connectionError("connection_service_unavailable");
152
+ }
153
+ if (!response.ok && health?.code === "connection_scope_changed" &&
154
+ ["web_origin", "account_id", "organization_id", "device_id"].includes(health.field)) {
155
+ const diagnostic = connectionDiagnostic(health.code, health.field);
156
+ throw Object.assign(new Error(diagnostic.error), diagnostic);
157
+ }
158
+ if (!response.ok) throw connectionError("connection_service_mismatch");
159
+ if (
160
+ !health?.ok ||
161
+ health.instance_id !== record.instance_id ||
162
+ health.pid !== record.pid ||
163
+ health.web_origin !== origin
164
+ ) {
165
+ throw connectionError("connection_service_mismatch");
166
+ }
167
+ assertConnectionScope(expectedScope, health.scope);
168
+ }
169
+ if (health.login_url !== null) {
170
+ let url;
171
+ try {
172
+ url = new URL(health.login_url);
173
+ } catch {
174
+ throw connectionError("connection_service_mismatch");
175
+ }
176
+ if (
177
+ url.origin !== origin ||
178
+ url.pathname !== "/local-cli/connect" ||
179
+ url.searchParams.get("port") !== String(record.port)
180
+ ) {
181
+ throw connectionError("connection_service_mismatch");
182
+ }
183
+ }
184
+ return {
185
+ port: record.port,
186
+ webOrigin: origin,
187
+ loginUrl: health.login_url,
188
+ sessionValid: health.session_valid === true,
189
+ };
190
+ }
191
+
192
+ function connectionError(code) {
193
+ const diagnostic = connectionDiagnostic(code);
194
+ return Object.assign(new Error(diagnostic.error), diagnostic);
195
+ }
196
+
197
+ function compatibleServiceVersion(running, requested) {
198
+ const version = (value) =>
199
+ typeof value === "string"
200
+ ? /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(value)
201
+ : null;
202
+ const existing = version(running),
203
+ target = version(requested);
204
+ return Boolean(
205
+ existing &&
206
+ target &&
207
+ existing[1] === target[1] &&
208
+ (existing[1] !== "0" || existing[2] === target[2]),
209
+ );
210
+ }
@@ -23,12 +23,12 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
23
23
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
24
24
  output('A task stops after repeated failures, and pauses for review when no progress is confirmed. Resume starts a new progress window.');
25
25
  output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
26
- output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-failures 3]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
27
- output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-failures 3]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original task identity, history and approvals.');
26
+ output('simy delivery continue --provider codex|claude --session ID [--execution-cwd PATH] [--max-no-progress-minutes 30] [--max-failures 3]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
27
+ output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--execution-cwd PATH] [--max-failures 3]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Standard start tracks checkpoints and reports done without certifying acceptance. Managed start freezes the original objective, optional user-selected deliveryLevel, and acceptance specification. Desktop Continue infers the original conversation goal first. Continue adopts an eligible ordinary loop with the same goal and failure budget; its first managed action reconciles prior work read-only. --execution-cwd explicitly binds the task directory while provider identity retains its original folder. Resume preserves original task identity, history and approvals.');
28
28
  return 0;
29
29
  }
30
30
  const options = {};
31
- const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-failures', 'max-no-progress-minutes', 'continuation-policy', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
31
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-failures', 'max-no-progress-minutes', 'continuation-policy', 'file', 'managed-file', 'execution-cwd', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
32
32
  for (let i = 0; i < args.length; i += 2) {
33
33
  const key = args[i]?.replace(/^--/, '');
34
34
  if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
@@ -37,7 +37,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
37
37
  const base = { root, cwd, provider: options.provider, session: options.session };
38
38
  let result;
39
39
  if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
40
- else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
40
+ else if (action === 'continue') result = await continueSession({ ...base, executionCwd: options['execution-cwd'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
41
41
  ...Object.fromEntries([['max-failures', 'maxFailures'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
42
42
  .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
43
43
  else if (action === 'handoff') result = await handoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
@@ -53,7 +53,7 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
53
53
  if (!managed || typeof managed !== 'object') throw new Error('--max-no-progress-minutes requires --managed-file; use delivery continue for context-first supervision.');
54
54
  managed = { ...managed, maxNoProgressMinutes: Number(options['max-no-progress-minutes']) };
55
55
  }
56
- result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed, continuationPolicy: options['continuation-policy'],
56
+ result = await startDelivery({ ...base, executionCwd: options['execution-cwd'], objective: options.objective, nextStep: options['next-step'], managed, continuationPolicy: options['continuation-policy'],
57
57
  ...(options['max-failures'] === undefined ? {} : { maxFailures: Number(options['max-failures']) }) });
58
58
  }
59
59
  else if (action === 'checkpoint') {
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { canReplanDelivery, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canReplanDelivery, pendingAcceptance, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
@@ -28,7 +28,7 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
28
28
  const latest = records.filter(r => r.provider === provider && Number.isFinite(r.observedAt)).sort((a, b) => b.observedAt - a.observedAt)[0];
29
29
  return [provider, { ...HOOK_SETUP[provider], state: latest ? 'observed' : 'not_observed', lastObservedAt: latest?.observedAt || null }];
30
30
  })),
31
- runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
31
+ runs: records.filter(r => r.run && r.providerArchived !== true).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
32
32
  provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
33
33
  objective: r.run.objective, state: r.run.state, reason: r.run.reason,
34
34
  continuations: r.run.continuations,
@@ -37,6 +37,9 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
37
37
  stopCode: deliveryStop(r.run, now)?.code ?? null,
38
38
  outcomeProgress: r.run.managed?.outcomeProgress || null,
39
39
  needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
40
+ mode: r.run.managed ? 'managed' : 'standard_loop',
41
+ deliveryLevel: r.run.managed?.deliveryLevel || null, enrollment: r.run.managed?.enrollment || null,
42
+ pendingAcceptance: pendingAcceptance(r.run),
40
43
  managed: Boolean(r.run.managed), taskId: r.run.managed?.taskId || null, actionId: r.run.managed?.actionId || null,
41
44
  actionDueAt: r.run.managed?.actionDueAt ?? null,
42
45
  actionAssociation: associationStatus(r.run.managed?.actionAssociation),
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
5
  import { resolveBackendExecutable } from '../backend-executable.js';
6
6
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
7
7
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
@@ -75,8 +75,9 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
75
75
  if (!binary) throw new Error('Codex executable unavailable.');
76
76
  await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
77
77
  const run = record.run;
78
- const context = { taskId: run.managed.taskId, session: record.session, objective: run.objective, nextStep: run.nextStep,
78
+ const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
79
79
  lastDependencyResolution: run.managed.lastDependencyResolution,
80
+ ...(run.managed.upgradedFrom ? { adoptedLoop: run.managed.upgradedFrom, priorCheckpoint: run.checkpoint } : {}),
80
81
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
81
82
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
82
83
  progress: deliveryProgressContext(run) };
@@ -252,7 +253,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
252
253
  result = { code: -3, busy: true };
253
254
  } else if (resource.busy) result = { code: -3, busy: true };
254
255
  else if (['local_edit', 'local_test'].includes(claimed.run.managed.decision?.action.kind) &&
255
- (await listRecords(root)).some(other => other.cwd === claimed.cwd && other.run?.managed?.executionUncertain)) result = { code: -3, busy: true };
256
+ (await listRecords(root)).some(other => sharesTaskWorkspace(other, claimed) && other.run?.managed?.executionUncertain)) result = { code: -3, busy: true };
256
257
  if (!result) {
257
258
  await authorizeClaim();
258
259
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
package/src/index.js CHANGED
@@ -6,6 +6,10 @@ import { createInterface } from "node:readline/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
9
+ import { findReusableConnection } from "./connection-discovery.js";
10
+ import { connectionScope } from "./connection-diagnostics.js";
11
+ import { readSession } from "./session-store.js";
12
+ import { CLI_API_CONTRACT_VERSION } from "./cli-contract.js";
9
13
  import { startAgent } from "./agent.js";
10
14
  import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
11
15
  import {
@@ -165,6 +169,23 @@ async function runLocalAgent() {
165
169
  const webOrigin = configuredWebOrigin;
166
170
  const sessionRoot = process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
167
171
 
172
+ try {
173
+ const existing = await findReusableConnection({ webOrigin, sessionRoot, cliVersion: packageInfo.version,
174
+ apiContractVersion: CLI_API_CONTRACT_VERSION, requestedPort: port, authorize: true,
175
+ expectedScope: connectionScope(await readSession(webOrigin, sessionRoot), webOrigin) });
176
+ if (existing) {
177
+ console.log(`Connected to existing SIMY service at http://127.0.0.1:${existing.port}`);
178
+ const url = existing.loginUrl || webOrigin;
179
+ console.log(`Continue in SIMY Web: ${url}`);
180
+ if (!args.has('--no-open') && process.env.SIMY_DAEMON_CHILD !== '1') await openAuthorizationUrl(url);
181
+ return;
182
+ }
183
+ } catch (error) {
184
+ console.error(error.message);
185
+ process.exitCode = 2;
186
+ return;
187
+ }
188
+
168
189
  try {
169
190
  await prepareDaemonWorkflows({ argv, daemon });
170
191
  } catch (error) {
@@ -0,0 +1,80 @@
1
+ import { webApiHeaders, webApiUrl } from "./web-api.js";
2
+
3
+ // Only an explicitly expired credential is renewable. A consumed or rejected
4
+ // credential may represent an execution that already started; never replay it.
5
+ export async function authorizeLaunch({
6
+ apiOrigin,
7
+ apiBaseUrl,
8
+ token,
9
+ runId,
10
+ challenge,
11
+ fetchImpl = fetch,
12
+ }) {
13
+ const post = async (path, body) => {
14
+ const response = await fetchImpl(
15
+ webApiUrl(path, { webOrigin: apiOrigin, apiBaseUrl }),
16
+ {
17
+ method: "POST",
18
+ headers: webApiHeaders(
19
+ { token },
20
+ { "Content-Type": "application/json" },
21
+ ),
22
+ body: JSON.stringify(body),
23
+ redirect: "error",
24
+ signal: AbortSignal.timeout(10_000),
25
+ },
26
+ );
27
+ return { response, payload: await response.json().catch(() => null) };
28
+ };
29
+ const rejected = ({ response, payload }) => ({
30
+ ok: false,
31
+ status: response.status >= 500 ? 502 : response.status === 401 ? 401 : 403,
32
+ code:
33
+ typeof payload?.code === "string"
34
+ ? payload.code
35
+ : response.status >= 500
36
+ ? "launch_service_unavailable"
37
+ : response.status === 401
38
+ ? "connection_session_expired"
39
+ : response.status === 404
40
+ ? "launch_service_incompatible"
41
+ : "launch_challenge_invalid",
42
+ });
43
+ const verificationOutcome = (result) => {
44
+ if (!result.response.ok) return rejected(result);
45
+ return result.payload?.ok === true
46
+ ? { ok: true }
47
+ : { ok: false, status: 502, code: "launch_service_unavailable" };
48
+ };
49
+ try {
50
+ if (challenge) {
51
+ const verified = await post("challenges/verify", {
52
+ run_id: runId,
53
+ challenge,
54
+ });
55
+ if (verified.response.ok) return verificationOutcome(verified);
56
+ if (verified.payload?.code !== "launch_challenge_expired")
57
+ return rejected(verified);
58
+ }
59
+ const issued = await post("challenges/issue", {
60
+ run_id: runId,
61
+ ...(challenge ? { previous_challenge: challenge } : {}),
62
+ });
63
+ if (!issued.response.ok) return rejected(issued);
64
+ if (
65
+ typeof issued.payload?.challenge !== "string" ||
66
+ !issued.payload.challenge ||
67
+ !Number.isFinite(Date.parse(issued.payload.expires_at)) ||
68
+ Date.parse(issued.payload.expires_at) <= Date.now()
69
+ ) {
70
+ return { ok: false, status: 502, code: "launch_service_unavailable" };
71
+ }
72
+ const verified = await post("challenges/verify", {
73
+ run_id: runId,
74
+ challenge: issued.payload.challenge,
75
+ });
76
+ return verificationOutcome(verified);
77
+ } catch {
78
+ return { ok: false, status: 502, code: "launch_service_unavailable" };
79
+ }
80
+ }
@@ -47,7 +47,15 @@ export async function collectPrEvidence({
47
47
  minimumCompletion.status = "evidence_incomplete";
48
48
  minimumCompletion.errors = ["Checkout changed while collecting minimum evidence; verify again."];
49
49
  }
50
+ const collectionErrors = [
51
+ ...(!baseRefresh.ok ? [baseRefresh.error] : []),
52
+ ...(!latest.available ? latest.errors : []),
53
+ // No reported PR is an implementation gap; an unreadable reported PR is
54
+ // a collector failure. Never send the worker to repair a failed gh read.
55
+ ...(github.collection_error ? [github.collection_error] : []),
56
+ ].filter(Boolean);
50
57
  return {
58
+ ...(collectionErrors.length ? { collection_error: collectionErrors.join("; ") } : {}),
51
59
  minimum_completion: minimumCompletion,
52
60
  collected_at: new Date().toISOString(),
53
61
  local: latest,
@@ -160,13 +168,13 @@ async function collectGitHubEvidence({ charter, attempt, repositoryPath, runComm
160
168
  ["pr", "view", target, "--json", fields],
161
169
  repositoryPath,
162
170
  );
163
- if (!result.ok) return unavailable(result.error);
171
+ if (!result.ok) return { ...unavailable(result.error), collection_error: result.error };
164
172
 
165
173
  let payload;
166
174
  try {
167
175
  payload = JSON.parse(result.stdout);
168
176
  } catch {
169
- return unavailable("GitHub PR evidence was not valid JSON.");
177
+ return { ...unavailable("GitHub PR evidence was not valid JSON."), collection_error: "GitHub PR evidence was not valid JSON." };
170
178
  }
171
179
  const checks = normalizeChecks(payload.statusCheckRollup);
172
180
  const requiredChecks = stringArray(charter.required_checks);
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { completedWorkerResult } from "../coding-run-recovery.js";
1
3
  import { validateCharter } from "./charter-validation.js";
2
4
  import { redactExecutionText } from "./execution-io.js";
3
5
  import { minimumCharter } from "./delivery/scripts/check-minimum-completion.mjs";
@@ -26,7 +28,7 @@ async function runCodingLoopOwned({
26
28
  shouldStop = () => false,
27
29
  consumeHumanGuidance = () => "",
28
30
  }) {
29
- if (!resume && snapshot.attempts.length > 0) {
31
+ if (!resume && snapshot.attempts.length > 0 && !snapshot.metadata?.completed_worker_result) {
30
32
  return recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm, onUpdate });
31
33
  }
32
34
  snapshot.implementation_gate_passed = false;
@@ -76,7 +78,16 @@ async function runCodingLoopOwned({
76
78
  }
77
79
 
78
80
  let previousFindings = resume ? snapshot.final_audit?.findings || [] : [];
79
- const firstAttempt = resume ? snapshot.attempts.length + 1 : 1;
81
+ const receipt = completedWorkerResult(snapshot);
82
+ if (snapshot.metadata?.completed_worker_result && !receipt) {
83
+ snapshot.final_audit = humanGate("The saved worker result cannot be safely reused.", [{
84
+ code: "WORKER_RESULT_UNVERIFIED", severity: "blocker", target: "worker_result",
85
+ explanation: "Reconcile the saved result and objective before continuing; do not replay unknown external effects.", repairability: "manual",
86
+ }]);
87
+ await publish(snapshot, "waiting_human", onUpdate);
88
+ return snapshot;
89
+ }
90
+ const firstAttempt = receipt?.attempt.attempt_number ?? (resume ? snapshot.attempts.length + 1 : 1);
80
91
  const lastAttempt = codingAttemptCeiling(snapshot);
81
92
  for (
82
93
  let attemptNumber = firstAttempt;
@@ -88,7 +99,8 @@ async function runCodingLoopOwned({
88
99
  attemptNumber,
89
100
  phase: "executor",
90
101
  });
91
- if (!executorBudget.allowed) {
102
+ const recoveredAttempt = attemptNumber === firstAttempt ? receipt?.attempt : null;
103
+ if (!recoveredAttempt && !executorBudget.allowed) {
92
104
  await stopForBudget({
93
105
  snapshot,
94
106
  attempt: snapshot.attempts.at(-1) ?? null,
@@ -107,7 +119,7 @@ async function runCodingLoopOwned({
107
119
  const retainedGuidance = redactExecutionText(attemptGuidance.trim(), { maxChars: Infinity }).text;
108
120
  if (history.at(-1) !== retainedGuidance) history.push(retainedGuidance);
109
121
  }
110
- const previousAttempt = snapshot.attempts.at(-1) ?? null;
122
+ const previousAttempt = snapshot.attempts.filter(item => item.attempt_number < attemptNumber).at(-1) ?? null;
111
123
  const promptInterventions = buildPromptInterventions(snapshot.charter, {
112
124
  attemptNumber,
113
125
  previousFindings,
@@ -121,37 +133,48 @@ async function runCodingLoopOwned({
121
133
  promptInterventions,
122
134
  });
123
135
 
124
- appendEvent(snapshot, "dispatching", `Dispatching local coding attempt ${attemptNumber}.`, {
125
- backend: snapshot.charter.backend,
126
- attempt_number: attemptNumber,
127
- instruction,
128
- });
129
- await publish(snapshot, "dispatching", onUpdate);
130
-
131
- appendEvent(snapshot, "coding", `${snapshot.charter.backend} is executing locally.`, {
132
- backend: snapshot.charter.backend,
133
- attempt_number: attemptNumber,
134
- instruction,
135
- });
136
- await publish(snapshot, "coding", onUpdate);
137
-
138
- const execution = await safeExecution(() =>
139
- executeAttempt({
140
- attemptNumber,
141
- backend: snapshot.charter.backend,
142
- instruction,
143
- charter: snapshot.charter,
144
- }),
145
- );
136
+ let attempt = recoveredAttempt;
137
+ if (!attempt) {
138
+ appendEvent(snapshot, "dispatching", `Dispatching local coding attempt ${attemptNumber}.`, {
139
+ backend: snapshot.charter.backend, attempt_number: attemptNumber, instruction,
140
+ });
141
+ await publish(snapshot, "dispatching", onUpdate);
142
+ appendEvent(snapshot, "coding", `${snapshot.charter.backend} is executing locally.`, {
143
+ backend: snapshot.charter.backend, attempt_number: attemptNumber, instruction,
144
+ });
145
+ await publish(snapshot, "coding", onUpdate);
146
+ const recordCompletion = async execution => {
147
+ attempt = buildAttempt({ attemptNumber, charter: snapshot.charter, instruction, promptInterventions, execution });
148
+ if (attempt.status !== "completed" || Array.isArray(attempt.raw_output) || !Object.keys(attempt.raw_output).length) return;
149
+ snapshot.metadata ??= {};
150
+ snapshot.metadata.completed_worker_result = {
151
+ version: 1, run_id: snapshot.id, invocation_id: execution.invocationId || randomUUID(),
152
+ objective_digest: verifyObjective(snapshot.charter).digest, attempt,
153
+ recorded_at: new Date().toISOString(),
154
+ };
155
+ // Do not enter collectors until the terminal result has been committed
156
+ // to private storage by onUpdate. A failed write must never become a
157
+ // synthetic executor failure that dispatches another worker.
158
+ try { await publish(snapshot, "coding", onUpdate); }
159
+ catch (error) {
160
+ snapshot.metadata.completed_worker_result.persistence_failed = true;
161
+ error.code = "worker_result_persistence_failed";
162
+ throw error;
163
+ }
164
+ };
165
+ const execution = await safeExecution(() => executeAttempt({
166
+ attemptNumber, backend: snapshot.charter.backend, instruction,
167
+ charter: snapshot.charter, onExecutionComplete: recordCompletion,
168
+ }));
169
+ if (!attempt) await recordCompletion(execution);
170
+ } else {
171
+ appendEvent(snapshot, "collecting_evidence", "Recovering the completed worker result without dispatching another worker.", {
172
+ code: "completed_worker_result_recovered", attempt_number: attemptNumber,
173
+ invocation_id: receipt.invocation_id,
174
+ });
175
+ }
146
176
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
147
- const attempt = buildAttempt({
148
- attemptNumber,
149
- charter: snapshot.charter,
150
- instruction,
151
- promptInterventions,
152
- execution,
153
- });
154
- attempt.budget = refreshBudgetState(snapshot, attempt);
177
+ attempt.budget = refreshBudgetState(snapshot, snapshot.attempts.some(item => item.attempt_number === attemptNumber) ? null : attempt);
155
178
 
156
179
  appendEvent(snapshot, "collecting_evidence", `Collecting attempt ${attemptNumber} evidence.`, {
157
180
  attempt_number: attemptNumber,
@@ -162,7 +185,11 @@ async function runCodingLoopOwned({
162
185
  tests_run: attempt.tests_run,
163
186
  });
164
187
  await publish(snapshot, "collecting_evidence", onUpdate);
165
- attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
188
+ attempt.observed_evidence = await safeEvidence(collectEvidence, attempt, { retries: snapshot.metadata?.completed_worker_result ? 2 : 0, shouldStop });
189
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
190
+ if (attempt.observed_evidence.collection_error && snapshot.metadata?.completed_worker_result) {
191
+ return waitForCollector(snapshot, attempt, onUpdate);
192
+ }
166
193
  attempt.sqm = await safeSqm(checkSqm, attempt);
167
194
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
168
195
 
@@ -183,7 +210,9 @@ async function runCodingLoopOwned({
183
210
  });
184
211
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
185
212
  attempt.implementation_gate = attempt.audit.implementation_gate;
186
- snapshot.attempts.push(attempt);
213
+ const recordedIndex = snapshot.attempts.findIndex(item => item.attempt_number === attemptNumber);
214
+ if (recordedIndex >= 0) snapshot.attempts[recordedIndex] = attempt;
215
+ else snapshot.attempts.push(attempt);
187
216
  assessOutcomeProgress(snapshot.charter, attempt, previousAttempt, { ...outcomeOptions(snapshot), resetUnavailableCount: resume && attemptNumber === firstAttempt });
188
217
  attempt.budget = refreshBudgetState(snapshot);
189
218
  recordBudgetWarnings(snapshot, attempt);
@@ -200,6 +229,7 @@ async function runCodingLoopOwned({
200
229
 
201
230
  if (!attempt.audit.passed) {
202
231
  snapshot.final_audit = attempt.audit;
232
+ delete snapshot.metadata?.completed_worker_result;
203
233
  const terminal = await handleFailedAudit({
204
234
  snapshot,
205
235
  attempt,
@@ -267,7 +297,11 @@ async function runCodingLoopOwned({
267
297
  attempt.independent_audit.objective_version = reviewedObjectiveVersion;
268
298
 
269
299
  // Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
270
- attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
300
+ attempt.observed_evidence = await safeEvidence(collectEvidence, attempt, { retries: snapshot.metadata?.completed_worker_result ? 2 : 0, shouldStop });
301
+ if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
302
+ if (attempt.observed_evidence.collection_error && snapshot.metadata?.completed_worker_result) {
303
+ return waitForCollector(snapshot, attempt, onUpdate);
304
+ }
271
305
  attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm);
272
306
  if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
273
307
  attempt.audit = await auditAttempt(snapshot.charter, attempt, {
@@ -282,6 +316,7 @@ async function runCodingLoopOwned({
282
316
  if (!attempt.audit.passed || !attempt.independent_audit.passed) {
283
317
  const findings = [...attempt.audit.findings, ...independentFindings];
284
318
  snapshot.final_audit = aggregateAudit(attempt, false, findings);
319
+ delete snapshot.metadata?.completed_worker_result;
285
320
  const terminal = await handleFailedAudit({
286
321
  snapshot,
287
322
  attempt,
@@ -296,6 +331,7 @@ async function runCodingLoopOwned({
296
331
  continue;
297
332
  }
298
333
 
334
+ delete snapshot.metadata?.completed_worker_result;
299
335
  snapshot.implementation_gate_passed = true;
300
336
  attempt.pr_readiness = evaluatePrReadiness(snapshot.charter, attempt);
301
337
  attempt.pr_gate = attempt.pr_readiness;
@@ -389,10 +425,23 @@ async function recheckPrReadinessOwned({ snapshot, collectEvidence, checkSqm = n
389
425
  }
390
426
 
391
427
 
428
+ async function waitForCollector(snapshot, attempt, onUpdate) {
429
+ snapshot.final_audit = humanGate("Evidence collection failed; the completed worker result is retained for retry.", [{
430
+ code: "EVIDENCE_COLLECTION_UNAVAILABLE", severity: "blocker", target: "evidence_collector",
431
+ explanation: attempt.observed_evidence.collection_error, repairability: "manual",
432
+ }]);
433
+ appendEvent(snapshot, "waiting_human", "Retry evidence collection for the same completed attempt.", {
434
+ code: "evidence_collection_retry_required", attempt_number: attempt.attempt_number,
435
+ });
436
+ await publish(snapshot, "waiting_human", onUpdate);
437
+ return snapshot;
438
+ }
439
+
392
440
  async function safeExecution(callback) {
393
441
  try {
394
442
  return await callback();
395
443
  } catch (error) {
444
+ if (error?.code === "worker_result_persistence_failed") throw error;
396
445
  return {
397
446
  exitCode: null,
398
447
  logs: [],
@@ -402,14 +451,19 @@ async function safeExecution(callback) {
402
451
  }
403
452
  }
404
453
 
405
- async function safeEvidence(collector, attempt) {
454
+ async function safeEvidence(collector, attempt, { retries = 0, shouldStop = () => false } = {}) {
406
455
  if (!collector) return unavailableEvidence("Evidence collector is not configured.");
407
- try {
408
- return await collector(attempt);
409
- } catch (error) {
410
- return unavailableEvidence(
411
- error instanceof Error ? error.message : "Evidence collection failed.",
412
- );
456
+ for (let retry = 0; ; retry++) {
457
+ if (retry > 0 && shouldStop()) return unavailableEvidence("Evidence collection stopped by the operator.");
458
+ try {
459
+ const evidence = await collector(attempt);
460
+ if (evidence?.collection_error && retry < retries) continue;
461
+ return evidence;
462
+ }
463
+ catch (error) {
464
+ if (retry < retries) continue;
465
+ return unavailableEvidence(error instanceof Error ? error.message : "Evidence collection failed.");
466
+ }
413
467
  }
414
468
  }
415
469
 
@@ -430,6 +484,7 @@ async function safeSqm(checker, attempt, previous = null, phase = "min") {
430
484
 
431
485
  function unavailableEvidence(error) {
432
486
  return {
487
+ collection_error: error,
433
488
  collected_at: new Date().toISOString(),
434
489
  local: {
435
490
  available: false,