@volter/supercode-orchestrator 0.5.187 → 0.5.188

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/README.md CHANGED
@@ -42,6 +42,17 @@ Rules the codec keeps (enforced in Rust, observed here):
42
42
  since import (UNI-22); it copies the store byte for byte when they did not.
43
43
  - Files the IR does not model are never touched by `save` and are carried by path on a migration.
44
44
 
45
+ ## Local native session recovery
46
+
47
+ `supercode sessions recover --once --retry-after 300` runs a bounded opt-in
48
+ recovery pass over this user's local Claude Code and Codex panes. The shared
49
+ terminal classifier identifies the current transient failure; the machine's
50
+ `panes.retry` door owns the guarded native input and retained receipt. This
51
+ package owns the retry interval, durable input IDs and reporting. A separate
52
+ SQLite owner lease prevents overlapping passes without retaining a lock after a
53
+ process dies. It does not change a board's dispatch policy or restart an
54
+ intentionally suspended session. See the [session guide](../../docs/guides/sessions.md#retry-transient-native-session-failures).
55
+
45
56
  ## OpenClaw, and the two migration verbs (ORC-12)
46
57
 
47
58
  ```js
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@volter/supercode-orchestrator",
3
- "version": "0.5.187",
3
+ "version": "0.5.188",
4
4
  "type": "module",
5
5
  "description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
6
6
  "exports": {
7
+ "./session-recovery": {
8
+ "types": "./session-recovery.d.ts",
9
+ "import": "./session-recovery.mjs"
10
+ },
7
11
  ".": {
8
12
  "types": "./index.d.ts",
9
13
  "import": "./index.mjs"
@@ -24,6 +28,7 @@
24
28
  "check-types": "node scripts/generate-types.mjs --check"
25
29
  },
26
30
  "dependencies": {
31
+ "@volter/supercode-terminal": "^0.3.8",
27
32
  "@volter/supercode-harness-sdk": "^0.3.101",
28
33
  "@volter/teams": "0.2.84",
29
34
  "@volter/ztrack": "^2.4.23",
@@ -0,0 +1,2 @@
1
+ /** One explicitly requested, bounded local Claude Code/Codex recovery pass. */
2
+ export function runSessionRecovery(args?: string[]): Promise<number>;
@@ -0,0 +1,157 @@
1
+ // The optional local-session retry policy. The machine owns observation and native input receipts;
2
+ // this orchestrator command owns when to ask for another turn. One invocation is bounded to eight seconds.
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { openLocalMachine, StreamRpc } from '@volter/teams/client';
8
+ import { supercodeHome } from '@volter/teams/home';
9
+ import { classifyRecoverableFailure } from '@volter/supercode-terminal';
10
+
11
+ const HELP = `supercode sessions recover [--once] [--home DIR] [--retry-after SECONDS]
12
+
13
+ Retry current transient Claude Code/Codex failures in this user's local panes.
14
+ Each pass reads every live panel and submits continue only for the current failed
15
+ turn with an empty composer. Suspended panes, user input and completed turns are
16
+ preserved. Delivery is confirmed by the machine's retained native receipt.
17
+
18
+ The default retry interval is 300 seconds per pane. Schedule this short command
19
+ with your service manager for recurring recovery. Policy and receipts are retained
20
+ under <supercode-home>/session-recovery; the machine daemon does not choose retries.
21
+ `;
22
+
23
+ function options(args) {
24
+ const out = { home: supercodeHome(), retryAfter: 300 };
25
+ for (let index = 0; index < args.length; index++) {
26
+ const arg = args[index];
27
+ if (arg === '--once') continue;
28
+ if (arg === '--help' || arg === '-h') return { help: true };
29
+ if (arg === '--home' || arg === '--retry-after') {
30
+ const value = args[++index];
31
+ if (!value || value.startsWith('--')) throw new Error(`${arg} needs a value`);
32
+ if (arg === '--home') out.home = resolve(value);
33
+ else {
34
+ out.retryAfter = Number(value);
35
+ if (!Number.isInteger(out.retryAfter) || out.retryAfter < 30 || out.retryAfter > 86400) throw new Error('--retry-after must be 30..86400 seconds');
36
+ }
37
+ } else throw new Error(`unknown recovery option: ${arg}`);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function readState(path) {
43
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
44
+ catch (error) { if (error.code === 'ENOENT') return { version: 1, attempts: {} }; throw error; }
45
+ }
46
+
47
+ /** Run the CLI's one short recovery pass. Stable message IDs survive command and daemon restarts. */
48
+ export async function runSessionRecovery(args = []) {
49
+ let opts;
50
+ try { opts = options(args); } catch (error) { process.stderr.write(`${error.message}\n${HELP}`); return 2; }
51
+ if (opts.help) { process.stdout.write(HELP); return 0; }
52
+ const started = Date.now(), deadline = started + 8000;
53
+ const summary = { at: new Date(started).toISOString(), scanned: 0, continued: [], confirmed: [], pending: [], skipped: [], errors: [] };
54
+ const directory = join(opts.home, 'session-recovery');
55
+ mkdirSync(directory, { recursive: true });
56
+ const statePath = join(directory, 'state.json'), logPath = join(directory, 'activity.jsonl');
57
+ let channel, owner, timer;
58
+ const log = value => {
59
+ if (existsSync(logPath) && statSync(logPath).size > 2_000_000) {
60
+ // Keep one previous log without making rotation fail on Windows when it already exists.
61
+ writeFileSync(`${logPath}.previous`, readFileSync(logPath)); writeFileSync(logPath, '');
62
+ }
63
+ appendFileSync(logPath, `${JSON.stringify(value)}\n`);
64
+ };
65
+ try {
66
+ // A separate SQLite owner lock lasts only for this pass. The state is committed before each side effect;
67
+ // a process death releases ownership without losing the idempotency key of input already submitted.
68
+ owner = new DatabaseSync(join(directory, 'owner.sqlite'));
69
+ try { owner.exec('BEGIN IMMEDIATE'); }
70
+ catch (error) {
71
+ if (/locked|busy/iu.test(error.message)) { process.stdout.write(`${JSON.stringify({ ...summary, skipped: ['another recovery pass is running'] })}\n`); return 0; }
72
+ throw error;
73
+ }
74
+ timer = setTimeout(() => {
75
+ log({ ...summary, durationMs: Date.now() - started, error: 'recovery pass exceeded its eight-second deadline' });
76
+ channel?.close(); owner?.close(); process.exit(1);
77
+ }, 8500);
78
+ const machine = JSON.parse(readFileSync(join(opts.home, 'teams', 'machine.json'), 'utf8'));
79
+ channel = await openLocalMachine(machine.socket, { timeoutMs: 1000 });
80
+ const rpc = new StreamRpc(await channel.open('node'));
81
+ const call = (method, params, maximum = 1800) => {
82
+ const timeoutMs = Math.min(maximum, deadline - Date.now());
83
+ if (timeoutMs <= 0) throw new Error(`recovery deadline reached before ${method}`);
84
+ return rpc.call(method, params, { timeoutMs });
85
+ };
86
+ const panes = await call('panes.processes', {});
87
+ const state = readState(statePath);
88
+ if (!state.attempts || typeof state.attempts !== 'object' || Array.isArray(state.attempts)) throw new Error('invalid recovery state: attempts must be an object');
89
+ const save = () => {
90
+ const temporary = `${statePath}.${process.pid}.tmp`;
91
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`); renameSync(temporary, statePath);
92
+ };
93
+ const current = new Map(panes.map(pane => [pane.pane, pane.pid]));
94
+ for (const [pane, attempt] of Object.entries(state.attempts)) if (!current.has(pane) || current.get(pane) !== attempt.pid) delete state.attempts[pane];
95
+ save();
96
+ const claim = { pid: process.pid, ppid: process.ppid, session: process.env.CODEX_THREAD_ID || process.env.CLAUDE_CODE_SESSION_ID || null };
97
+ const retry = (pane, ids, statusOnly = false) => call('panes.retry', {
98
+ pane: pane.pane, expected_pid: pane.pid, message_ids: ids, status_only: statusOnly, foreign: true, claim,
99
+ }, statusOnly ? 1500 : 3500);
100
+
101
+ await Promise.all(panes.map(async pane => {
102
+ try {
103
+ const panel = await call('panes.capture', { pane: pane.pane, lines: 100 });
104
+ summary.scanned++;
105
+ if (!['codex', 'claude-code'].includes(pane.kind)) return;
106
+ const previous = state.attempts[pane.pane];
107
+ if (previous && ['queued', 'sending'].includes(previous.status)) {
108
+ const receipt = await retry(pane, previous.messageIds, true);
109
+ if (receipt.delivered) {
110
+ previous.status = 'confirmed'; previous.lastStatus = receipt.receipt; save();
111
+ summary.confirmed.push({ pane: pane.pane, message_ids: previous.messageIds });
112
+ log({ at: new Date().toISOString(), event: 'continue-confirmed', pane: pane.pane, receipt });
113
+ } else if (receipt.refused || receipt.skipped) {
114
+ previous.status = 'refused'; previous.lastStatus = receipt.reason ?? receipt.skipped; save();
115
+ } else if (receipt.known || Date.now() - previous.at < opts.retryAfter * 1000) {
116
+ summary.pending.push({ pane: pane.pane, reason: receipt.reason ?? 'awaiting native acknowledgement', ageSeconds: Math.round((Date.now() - previous.at) / 1000) });
117
+ return;
118
+ } else {
119
+ // A timed-out request which was never accepted may be retried, only after another current-error read.
120
+ previous.status = 'not-submitted'; save();
121
+ }
122
+ }
123
+ const failure = classifyRecoverableFailure(panel.text);
124
+ if (!failure) { summary.skipped.push({ pane: pane.pane, reason: 'no current recoverable failure' }); return; }
125
+ if (Date.now() - (state.attempts[pane.pane]?.at ?? 0) < opts.retryAfter * 1000) {
126
+ summary.skipped.push({ pane: pane.pane, reason: 'retry interval has not elapsed' }); return;
127
+ }
128
+ // An unanswered submission keeps its original ID even when the daemon cannot find it yet.
129
+ // If it later completes, the same envelope is acknowledged rather than pasted a second time.
130
+ const messageIds = previous?.status === 'not-submitted' ? previous.messageIds : [`recover-${randomUUID()}`];
131
+ const attempt = { at: Date.now(), pid: pane.pid, failure, messageIds, status: 'sending' };
132
+ state.attempts[pane.pane] = attempt; save();
133
+ log({ at: new Date().toISOString(), event: 'continue-attempt', pane: pane.pane, ...attempt });
134
+ let result = await retry(pane, attempt.messageIds);
135
+ const until = Math.min(Date.now() + 1200, deadline - 300);
136
+ while (result.queued && Date.now() < until) {
137
+ await new Promise(done => setTimeout(done, 200));
138
+ result = await retry(pane, attempt.messageIds, true);
139
+ }
140
+ attempt.status = result.delivered ? 'confirmed' : result.refused ? 'refused' : result.skipped ? 'skipped' : result.queued || result.known ? 'queued' : 'refused';
141
+ attempt.lastStatus = result.reason ?? result.skipped ?? result.receipt; save();
142
+ log({ at: new Date().toISOString(), event: 'continue-result', pane: pane.pane, result });
143
+ if (result.delivered) summary.continued.push({ pane: pane.pane, failure: failure.kind });
144
+ else if (result.skipped) summary.skipped.push({ pane: pane.pane, reason: result.skipped });
145
+ else if (!result.refused && (result.queued || result.known)) summary.pending.push({ pane: pane.pane, reason: result.reason ?? 'awaiting native acknowledgement', ageSeconds: Math.round((Date.now() - attempt.at) / 1000) });
146
+ else summary.errors.push({ pane: pane.pane, message: result.reason ?? JSON.stringify(result) });
147
+ } catch (error) { summary.errors.push({ pane: pane.pane, message: error.message }); }
148
+ }));
149
+ } catch (error) { summary.errors.push({ message: error.message }); }
150
+ finally {
151
+ clearTimeout(timer); channel?.close(); owner?.close();
152
+ }
153
+ summary.durationMs = Date.now() - started;
154
+ log(summary); writeFileSync(join(directory, 'last-run.json'), `${JSON.stringify(summary, null, 2)}\n`);
155
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
156
+ return summary.errors.length ? 1 : 0;
157
+ }