@volter/supercode-orchestrator 0.5.227 → 0.5.229
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 +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
|
@@ -1,21 +1,26 @@
|
|
|
1
|
+
import { nativeJsonEqual } from '@volter/supercode-interchange/native-json';
|
|
2
|
+
import { measureWork, registerWorkOperations, stopWorkChild } from "@volter/supercode-harness-sdk/work-ledger";
|
|
3
|
+
registerWorkOperations([{"kind":"pass","operation":"board-owner.record"},{"kind":"pass","operation":"board-owner.activate"},{"kind":"pass","operation":"board-owner.prepare"},{"kind":"pass","operation":"board-owner.rollback"},{"kind":"pass","operation":"board-owner.switch"},{"kind":"door","operation":"board-owner.control"},{"kind":"door","operation":"board-owner.command"}]);
|
|
4
|
+
import { registerWorkService, closeWorkLedger } from '@volter/supercode-harness-sdk/work-ledger';
|
|
1
5
|
// The stable board owner: one OS lock and public door, with replaceable, fenced dispatcher generations behind it.
|
|
2
6
|
// A request already sent to a generation is never replayed. Only unsent requests wait at the handover boundary.
|
|
3
7
|
import { readSourceCandidate } from '@volter/supercode-teams/source-selection';
|
|
4
8
|
import { sourceLockPath } from '@volter/supercode-teams/source-lock';
|
|
9
|
+
import { dropOwnCallerIdentity } from '@volter/supercode-teams/service-environment';
|
|
5
10
|
import { spawn } from 'node:child_process';
|
|
6
11
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { join, resolve } from 'node:path';
|
|
12
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
8
13
|
import { fileURLToPath } from 'node:url';
|
|
9
14
|
import { randomUUID } from 'node:crypto';
|
|
10
15
|
import { boardHome, boardOwnerSocket } from '@volter/teams/board-owner';
|
|
11
16
|
import { retainedReceipt } from '@volter/supercode-teams/release-selection';
|
|
12
|
-
import { sourceTransaction } from '@volter/supercode-teams/serving-source-transaction';
|
|
17
|
+
import { sourceTransaction, readBoardSourceReservation } from '@volter/supercode-teams/serving-source-transaction';
|
|
13
18
|
import { holdLock } from './lock-holder.mjs';
|
|
14
19
|
import { openOwnerDoor } from './owner-door.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
20
|
+
import { holdWorkflow } from './owner-interface.mjs';
|
|
21
|
+
import { knowLocalMachine, localMachine } from './owner-interface.mjs';
|
|
22
|
+
import { parse } from './owner-interface.mjs';
|
|
23
|
+
import { publishStandaloneDeclaration } from './standalone-declaration.mjs';
|
|
19
24
|
|
|
20
25
|
const unknown = () => ({ code: 1, unanswered: true, stdout: '', stderr: 'the board generation ended after taking this request; its outcome is unknown\n' });
|
|
21
26
|
const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error.message}\n` });
|
|
@@ -25,21 +30,45 @@ const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${e
|
|
|
25
30
|
const READS = new Set(['show', 'list', 'ls', 'context', 'brief', 'runs', 'log', 'stats', 'assignees', 'attachments', 'attachment-read', 'notify-list']);
|
|
26
31
|
const READ_MS = 8000;
|
|
27
32
|
|
|
28
|
-
export async function serveBoardOwner(root, args) {
|
|
33
|
+
export async function serveBoardOwner(root, args, firstReaderEpoch) {
|
|
34
|
+
// Service admission (runWorkflow) was the starting caller's act. The owner and every generation, lock holder and
|
|
35
|
+
// command it starts afterwards act as the install, never as that caller (ADR 0080, "Resident startup"; ADR 0013,
|
|
36
|
+
// "A service carries no caller's identity"): a card agent's context kept here would refuse every remote launch once
|
|
37
|
+
// that agent ended.
|
|
38
|
+
dropOwnCallerIdentity();
|
|
29
39
|
root = boardHome(root);
|
|
40
|
+
registerWorkService({role:'board-owner',scope:{kind:'install',id:root}});
|
|
30
41
|
mkdirSync(root, { recursive: true });
|
|
42
|
+
// The service owns its working directory; the file arguments stay the caller's. A path the starter named is bound
|
|
43
|
+
// against the starter's directory here, before the owner leaves it for its home: a removed starter directory would
|
|
44
|
+
// otherwise fail every generation it starts (and the harness service each runs) at process.cwd(), so no handover
|
|
45
|
+
// could prepare a successor.
|
|
46
|
+
const pidfile = args.pidfile ? resolve(args.pidfile) : null;
|
|
47
|
+
process.chdir(root);
|
|
31
48
|
const log = line => process.stdout.write(`${new Date().toISOString()} board owner: ${line}\n`);
|
|
32
|
-
let stopping = false, active = null, previous = null, transition = null, routing = null, pending = null, recoveryFailure = null;
|
|
49
|
+
let stopping = false, active = null, previous = null, transition = null, routing = null, pending = null, recoveryFailure = null, lastRestingReader = null;
|
|
33
50
|
const generations = new Set(), waiting = new Set();
|
|
51
|
+
// Generations told to end that outlived TERM and KILL, by pid: named in the generation record and the status until
|
|
52
|
+
// their process exits, so a superseded worker is never left running silently.
|
|
53
|
+
const unended = new Map();
|
|
34
54
|
const recordPath = join(root, 'board-generation.json');
|
|
35
|
-
const record = child => {
|
|
55
|
+
const record = (child, stopped = false) => {
|
|
56
|
+
return measureWork("pass", "board-owner.record", () => {
|
|
36
57
|
const temporary = `${recordPath}.${randomUUID()}.tmp`;
|
|
37
|
-
writeFileSync(temporary, JSON.stringify({ version: 1, owner: process.pid, active: child?.process.pid ?? null,
|
|
38
|
-
selected: child?.selected ?? null, generation: child?.id ?? null, previous: previous && !previous.ended ? previous.process.pid : null, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
58
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, owner: stopped ? null : process.pid, active: child?.process.pid ?? null,
|
|
59
|
+
selected: child?.selected ?? null, generation: child?.id ?? null, previous: stopped ? null : previous && !previous.ended ? previous.process.pid : null, unended: [...unended.values()], restingReaderProtocol: 1, lastRestingReader, stopped, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
39
60
|
renameSync(temporary, recordPath);
|
|
61
|
+
|
|
62
|
+
});
|
|
40
63
|
};
|
|
41
64
|
const changed = () => { for (const done of waiting) done(); waiting.clear(); };
|
|
42
65
|
const lock = await holdLock(join(root, 'board.lock'), { onLost: error => { log(error.message); process.exit(1); } });
|
|
66
|
+
// Only the lock holder lends actual acknowledged reader history. A candidate selected before writer grant
|
|
67
|
+
// cannot become this record; an older writer invalidates it before activation.
|
|
68
|
+
try {
|
|
69
|
+
const prior = JSON.parse(readFileSync(recordPath, 'utf8'));
|
|
70
|
+
if (prior.version === 1 && prior.restingReaderProtocol === 1 && prior.lastRestingReader?.version === 1) lastRestingReader = prior.lastRestingReader;
|
|
71
|
+
} catch { /* no retained reader: conservative legacy admission */ }
|
|
43
72
|
await knowLocalMachine();
|
|
44
73
|
const entry = resolve(process.env.SUPERCODE_ORCHESTRATOR_ENTRY || fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
|
|
45
74
|
const start = selected => {
|
|
@@ -57,7 +86,7 @@ export async function serveBoardOwner(root, args) {
|
|
|
57
86
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], serialization: 'advanced',
|
|
58
87
|
env,
|
|
59
88
|
});
|
|
60
|
-
const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false,
|
|
89
|
+
const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, workflows: new Map(),
|
|
61
90
|
heard: Date.now(), silenceSaid: false };
|
|
62
91
|
generation.exited = new Promise(done => { processChild.once('exit', done); processChild.once('error', done); });
|
|
63
92
|
generations.add(generation);
|
|
@@ -93,8 +122,8 @@ export async function serveBoardOwner(root, args) {
|
|
|
93
122
|
generation.ended = true;
|
|
94
123
|
// IPC loss revokes the child but does not prove its process ended. End that exact owned child before recovery.
|
|
95
124
|
if (processChild.exitCode === null && processChild.signalCode === null) {
|
|
96
|
-
processChild.kill('SIGTERM');
|
|
97
|
-
const bound = setTimeout(() => processChild.kill('SIGKILL'), 2000);
|
|
125
|
+
stopWorkChild(processChild, () => processChild.kill('SIGTERM'), { signal: 'SIGTERM' });
|
|
126
|
+
const bound = setTimeout(() => stopWorkChild(processChild, () => processChild.kill('SIGKILL'), { signal: 'SIGKILL' }), 2000);
|
|
98
127
|
generation.exited.finally(() => clearTimeout(bound));
|
|
99
128
|
}
|
|
100
129
|
generations.delete(generation);
|
|
@@ -120,6 +149,21 @@ export async function serveBoardOwner(root, args) {
|
|
|
120
149
|
processChild.on('message', frame => {
|
|
121
150
|
generation.heard = Date.now(); generation.silenceSaid = false;
|
|
122
151
|
if (!frame || typeof frame.type !== 'string') return;
|
|
152
|
+
if (frame.type === 'resting-writer-granted') {
|
|
153
|
+
try {
|
|
154
|
+
const proof = generation.events.get('prepared');
|
|
155
|
+
if (!generation.writerRequested || generation.ended || frame.generation !== generation.id ||
|
|
156
|
+
typeof frame.request !== 'string' || frame.request.length > 128 || proof?.restingReaderProtocol !== 1 || !proof.resting)
|
|
157
|
+
throw new Error('generation has no matching prepared resting-state writer grant');
|
|
158
|
+
const next = { version: 1, selected: generation.selected, resting: proof.resting };
|
|
159
|
+
if (!nativeJsonEqual(lastRestingReader, next)) lastRestingReader = next;
|
|
160
|
+
record(generation);
|
|
161
|
+
tell({ type: 'resting-reader-recorded', request: frame.request, generation: generation.id });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
try { tell({ type: 'resting-reader-recorded', request: frame.request, generation: generation.id, error: error.message }); } catch { /* child disconnected */ }
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
123
167
|
if (frame.type === 'startup-step') {
|
|
124
168
|
generation.startupStep = frame.step;
|
|
125
169
|
if (Number.isSafeInteger(frame.budget_ms) && frame.budget_ms >= 1 && frame.budget_ms <= 65000)
|
|
@@ -127,7 +171,6 @@ export async function serveBoardOwner(root, args) {
|
|
|
127
171
|
return;
|
|
128
172
|
}
|
|
129
173
|
if (frame.type === 'answer') { generation.calls.get(frame.id)?.(frame.answer); generation.calls.delete(frame.id); changed(); return; }
|
|
130
|
-
if (frame.type === 'health') { generation.health = frame; if (routing === generation) holdOwnerHealth(frame.boards, frame.round); return; }
|
|
131
174
|
if (frame.type === 'workflow') { generation.workflows.set(frame.root, frame.entry); if (routing === generation) holdWorkflow(frame.root, frame.entry); return; }
|
|
132
175
|
const waiter = generation.events.get(frame.type);
|
|
133
176
|
if (waiter?.done) { clearTimeout(waiter.timer); waiter.done(frame); }
|
|
@@ -136,11 +179,32 @@ export async function serveBoardOwner(root, args) {
|
|
|
136
179
|
processChild.once('error', ended); processChild.once('exit', ended); processChild.once('disconnect', ended);
|
|
137
180
|
return generation;
|
|
138
181
|
};
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
182
|
+
// Ending a generation: TERM, KILL 2 s later, then 3 s for its exit. Each end is said in this owner's log with why;
|
|
183
|
+
// one that has not exited 5 s after TERM is said, kept in `unended` (record and status) until it exits, and resolves
|
|
184
|
+
// false, so no caller waits on it forever. Ending is idempotent: a generation already ended answers at once.
|
|
185
|
+
const terminate = (generation, why) => generation.ending ??= (async () => {
|
|
186
|
+
const child = generation.process, began = Date.now();
|
|
187
|
+
const exited = ms => {
|
|
188
|
+
let timer;
|
|
189
|
+
return Promise.race([generation.exited.then(() => true), new Promise(done => { timer = setTimeout(() => done(false), ms); })]).finally(() => clearTimeout(timer));
|
|
190
|
+
};
|
|
191
|
+
const name = `generation ${generation.id} (pid ${child.pid})`;
|
|
192
|
+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) { await generation.exited; return true; }
|
|
193
|
+
stopWorkChild(child, () => child.kill('SIGTERM'), { signal: 'SIGTERM' });
|
|
194
|
+
let by = 'TERM';
|
|
195
|
+
if (!await exited(2000)) {
|
|
196
|
+
by = 'KILL'; stopWorkChild(child, () => child.kill('SIGKILL'), { signal: 'SIGKILL' });
|
|
197
|
+
if (!await exited(3000)) {
|
|
198
|
+
unended.set(child.pid, { pid: child.pid, generation: generation.id, why, since_ms: began });
|
|
199
|
+
log(`${name} did not exit ${Date.now() - began} ms after TERM and KILL (${why}); it is still running and is named in board-generation.json until it exits`);
|
|
200
|
+
generation.exited.then(() => { if (unended.delete(child.pid)) { log(`${name} exited ${Date.now() - began} ms after it was told to end`); record(active); } });
|
|
201
|
+
generation.ending = null; record(active);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
log(`${name} ended by ${by} in ${Date.now() - began} ms: ${why}`);
|
|
206
|
+
return true;
|
|
207
|
+
})();
|
|
144
208
|
// Whether every request already sent to a generation was answered (or the generation ended) within the bound.
|
|
145
209
|
// New requests are held at the door meanwhile, so nothing more is sent to it.
|
|
146
210
|
const drained = (generation, ms = 8000) => {
|
|
@@ -150,8 +214,11 @@ export async function serveBoardOwner(root, args) {
|
|
|
150
214
|
}), new Promise(done => { timer = setTimeout(() => done(false), ms); })]).finally(() => clearTimeout(timer));
|
|
151
215
|
};
|
|
152
216
|
const activate = async generation => {
|
|
217
|
+
return measureWork("pass", "board-owner.activate", async () => {
|
|
153
218
|
if (stopping || generation.ended) throw new Error('cannot activate an ended board generation');
|
|
219
|
+
if (generation.events.get('prepared')?.restingReaderProtocol !== 1) lastRestingReader = null;
|
|
154
220
|
record(generation);
|
|
221
|
+
generation.writerRequested = true;
|
|
155
222
|
generation.events.delete('routable'); generation.events.delete('serving');
|
|
156
223
|
generation.tell({ type: 'activate' });
|
|
157
224
|
log(`generation ${generation.id} (pid ${generation.process.pid}) activates`);
|
|
@@ -171,13 +238,26 @@ export async function serveBoardOwner(root, args) {
|
|
|
171
238
|
// before asking each store for stats; an assumed default would refuse an otherwise serving empty home.
|
|
172
239
|
const boards = serving.boards;
|
|
173
240
|
if (!Array.isArray(boards)) throw new Error('successor board inventory was unreadable; predecessor retained');
|
|
174
|
-
|
|
241
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
242
|
+
const count = value => Number.isSafeInteger(value) && value >= 0;
|
|
243
|
+
const counts = value => object(value) && Object.values(value).every(count);
|
|
244
|
+
const grouped = value => object(value) && Object.values(value).every(counts);
|
|
245
|
+
const declaration = await read(['declaration', '--json']);
|
|
246
|
+
if (!object(declaration) || typeof declaration.board !== 'string'
|
|
247
|
+
|| ![null, 'sqlite', 'files', 'ztrack'].includes(declaration.backing)
|
|
248
|
+
|| !(declaration.declared === null || object(declaration.declared)))
|
|
249
|
+
throw new Error('successor declaration readback was not a board declaration; predecessor retained');
|
|
175
250
|
for (const slug of boards) {
|
|
176
251
|
if (typeof slug !== 'string') throw new Error('successor board inventory has no slug; predecessor retained');
|
|
177
|
-
await read(['stats', '--json', '--board', slug]);
|
|
252
|
+
const stats = await read(['stats', '--json', '--board', slug]);
|
|
253
|
+
if (!object(stats) || !counts(stats.by_status) || !counts(stats.by_lane)
|
|
254
|
+
|| !grouped(stats.by_assignee) || !grouped(stats.by_machine)
|
|
255
|
+
|| !(stats.oldest_ready_age_seconds === null || Number.isFinite(stats.oldest_ready_age_seconds))
|
|
256
|
+
|| !object(stats.backlog) || !count(stats.backlog.total)
|
|
257
|
+
|| !counts(stats.backlog.by_assignee) || !counts(stats.backlog.by_machine) || !Number.isFinite(stats.now))
|
|
258
|
+
throw new Error(`successor ${slug} stats readback was not board statistics; predecessor retained`);
|
|
178
259
|
}
|
|
179
260
|
active = generation; routing = generation; recoveryFailure = null;
|
|
180
|
-
if (generation.health) holdOwnerHealth(generation.health.boards, generation.health.round);
|
|
181
261
|
for (const [at, value] of generation.workflows) holdWorkflow(at, value);
|
|
182
262
|
changed();
|
|
183
263
|
log(`generation ${generation.id} routed (pid ${generation.process.pid})`);
|
|
@@ -193,13 +273,26 @@ export async function serveBoardOwner(root, args) {
|
|
|
193
273
|
}
|
|
194
274
|
throw error;
|
|
195
275
|
}
|
|
276
|
+
publishStandaloneDeclaration(root, generation.selected);
|
|
196
277
|
log(`generation ${generation.id} serves (pid ${generation.process.pid})`);
|
|
278
|
+
// Its first clean round recorded, this generation is the board's one writer. No path resumes a parked or superseded
|
|
279
|
+
// generation (a rollback after parking starts a fresh one from the predecessor's selection), so every other
|
|
280
|
+
// generation ends here, never waiting on the transaction's finish: only a prepared candidate not yet activated stays.
|
|
281
|
+
for (const other of [...generations]) {
|
|
282
|
+
if (other === generation || (pending?.phase === 'prepared' && other === pending.candidate)) continue;
|
|
283
|
+
await terminate(other, `superseded by generation ${generation.id} (pid ${generation.process.pid}) after its first clean round`);
|
|
284
|
+
}
|
|
285
|
+
record(generation);
|
|
197
286
|
return generation;
|
|
287
|
+
|
|
288
|
+
});
|
|
198
289
|
};
|
|
199
|
-
const status = () => ({ version: 1, protocol: 1, owner: process.pid, state: routing ? 'serving' : 'transitioning',
|
|
200
|
-
|
|
201
|
-
|
|
290
|
+
const status = () => ({ version: 1, protocol: 1, restingReaderProtocol: 1, lastRestingReader, owner: process.pid, state: routing ? 'serving' : 'transitioning',
|
|
291
|
+
firstReaderEpoch: !active && !lastRestingReader ? firstReaderEpoch : null,
|
|
292
|
+
generation: active?.id, pid: active?.process.pid, selected: active?.selected, previous: previous?.selected ?? pending?.before ?? null,
|
|
293
|
+
transaction: pending?.id ?? null, phase: pending?.phase ?? null, failure: recoveryFailure?.message ?? null, unended: [...unended.values()] });
|
|
202
294
|
const prepare = async (id, selected) => {
|
|
295
|
+
return measureWork("pass", "board-owner.prepare", async () => {
|
|
203
296
|
if (pending) {
|
|
204
297
|
if (pending.id === id) {
|
|
205
298
|
if (JSON.stringify(pending.candidate.selected) !== JSON.stringify(selected)) throw new Error('board transaction already names different inputs');
|
|
@@ -218,27 +311,33 @@ export async function serveBoardOwner(root, args) {
|
|
|
218
311
|
// modules' source, which is then compared.
|
|
219
312
|
const same = proof.format && held?.format ? proof.format === held.format || (Array.isArray(proof.readableBy) && proof.readableBy.includes(held.format)) : Boolean(proof.storage) && proof.storage === held?.storage;
|
|
220
313
|
if (!same) throw new Error('board storage contract differs; predecessor retained without opening a writer');
|
|
221
|
-
pending = { id, candidate, predecessor: active, older: previous, phase: 'prepared' };
|
|
314
|
+
pending = { id, candidate, predecessor: active, before: active.selected, older: previous, phase: 'prepared' };
|
|
222
315
|
return status();
|
|
223
|
-
} catch (error) { await terminate(candidate); throw error; }
|
|
316
|
+
} catch (error) { await terminate(candidate, `its preparation failed: ${error.message}`); throw error; }
|
|
317
|
+
|
|
318
|
+
});
|
|
224
319
|
};
|
|
225
320
|
const rollback = async () => {
|
|
321
|
+
return measureWork("pass", "board-owner.rollback", async () => {
|
|
226
322
|
const held = pending;
|
|
227
323
|
if (!held) return status();
|
|
228
|
-
await terminate(held.candidate);
|
|
324
|
+
await terminate(held.candidate, 'the handover rolls back');
|
|
229
325
|
previous = held.older;
|
|
230
326
|
if (held.phase !== 'prepared') {
|
|
231
327
|
routing = null;
|
|
232
328
|
// A timed-out park may still be executing. End that exact child before a fresh predecessor takes an epoch;
|
|
233
329
|
// an activate sent to a child still parking can otherwise race its final writer revocation.
|
|
234
|
-
await terminate(held.predecessor);
|
|
235
|
-
const replacement = start(held.
|
|
330
|
+
if (held.predecessor) await terminate(held.predecessor, 'the handover rolls back after it parked; a fresh generation starts from its selection');
|
|
331
|
+
const replacement = start(held.before);
|
|
236
332
|
try { await replacement.wait('prepared'); await activate(replacement); }
|
|
237
|
-
catch (error) { await terminate(replacement); throw error; }
|
|
333
|
+
catch (error) { await terminate(replacement, `its activation failed: ${error.message}`); throw error; }
|
|
238
334
|
} else if (!held.predecessor.ended) { active = routing = held.predecessor; changed(); }
|
|
239
335
|
pending = null; record(active); return status();
|
|
336
|
+
|
|
337
|
+
});
|
|
240
338
|
};
|
|
241
339
|
const switchPrepared = async () => {
|
|
340
|
+
return measureWork("pass", "board-owner.switch", async () => {
|
|
242
341
|
const held = pending;
|
|
243
342
|
if (held.phase === 'activated' && routing === held.candidate) return status();
|
|
244
343
|
if (held.phase !== 'prepared' || active !== held.predecessor) throw new Error('board preparation is no longer current');
|
|
@@ -256,44 +355,57 @@ export async function serveBoardOwner(root, args) {
|
|
|
256
355
|
await held.predecessor.wait('parked'); previous = held.predecessor;
|
|
257
356
|
await activate(held.candidate); held.phase = 'activated'; return status();
|
|
258
357
|
} catch (error) { await rollback(); throw error; }
|
|
358
|
+
|
|
359
|
+
});
|
|
259
360
|
};
|
|
260
|
-
|
|
361
|
+
// A handover's file operands are its caller's, read against the directory its request carries (board-owner.mjs).
|
|
362
|
+
const control = async (parsed, cwd) => {
|
|
363
|
+
// Every operand, absolute or relative, goes through one resolve, so a selection compares in one canonical form.
|
|
364
|
+
const callerPath = value => {
|
|
365
|
+
const base = typeof cwd === 'string' && isAbsolute(cwd) ? cwd : null;
|
|
366
|
+
if (!base && !isAbsolute(value)) throw new Error('a relative handover path needs the caller\'s working directory');
|
|
367
|
+
return base ? resolve(base, value) : resolve(value);
|
|
368
|
+
};
|
|
369
|
+
return measureWork("door", "board-owner.control", async () => {
|
|
261
370
|
const action = parsed.action ?? 'replace', id = parsed.transaction;
|
|
262
371
|
if (action === 'status') return status();
|
|
263
372
|
if (typeof id !== 'string' || !id) throw new Error('board handover needs --transaction');
|
|
264
373
|
if (action === 'prepare' || action === 'replace') {
|
|
265
374
|
if (!parsed.entry || !parsed.supercode) throw new Error('handover needs --entry and --supercode from one prepared machine release');
|
|
266
|
-
await prepare(id, { entry:
|
|
375
|
+
await prepare(id, { entry: callerPath(parsed.entry), native: callerPath(parsed.supercode), candidate: parsed.candidate ? callerPath(parsed.candidate) : null, release: parsed.release ? callerPath(parsed.release) : null });
|
|
267
376
|
} else if (pending?.id !== id) throw new Error('this board holds no matching handover transaction');
|
|
268
|
-
if (pending) sourceTransaction(id, pending.candidate.selected, pending.
|
|
377
|
+
if (pending) sourceTransaction(id, pending.candidate.selected, pending.before, action === 'replace' ? 'activate' : action, process.env, 'board');
|
|
269
378
|
if (action === 'activate' || action === 'replace') await switchPrepared();
|
|
270
379
|
if (action === 'rollback' || action === 'abort') return rollback();
|
|
271
380
|
if (action === 'finish' || action === 'replace') {
|
|
272
|
-
sourceTransaction(id, pending.candidate.selected, pending.
|
|
381
|
+
sourceTransaction(id, pending.candidate.selected, pending.before, 'finish', process.env, 'board');
|
|
273
382
|
if (pending.phase !== 'activated') throw new Error('board successor was never activated');
|
|
274
383
|
if (pending.candidate.ended) {
|
|
275
|
-
await terminate(pending.candidate);
|
|
384
|
+
await terminate(pending.candidate, 'the activated successor ended before the handover finished');
|
|
276
385
|
const replacement = start(pending.candidate.selected);
|
|
277
386
|
try { await replacement.wait('prepared'); await activate(replacement); pending.candidate = replacement; }
|
|
278
|
-
catch (error) { await terminate(replacement); throw error; }
|
|
387
|
+
catch (error) { await terminate(replacement, `its activation failed: ${error.message}`); throw error; }
|
|
279
388
|
}
|
|
280
389
|
if (routing !== pending.candidate || active.ended) throw new Error('board successor is not serving; handover cannot finish');
|
|
281
|
-
// The
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
390
|
+
// The successor's first clean round already ended its predecessor (activate); any generation still held but the
|
|
391
|
+
// serving one ends here too. `previous` keeps naming the selection it served.
|
|
392
|
+
for (const retained of [...generations]) if (retained !== active) await terminate(retained, `the handover ${id} finished`);
|
|
393
|
+
publishStandaloneDeclaration(root, active.selected);
|
|
285
394
|
pending = null; record(active);
|
|
286
395
|
} else if (!['prepare', 'activate'].includes(action)) throw new Error('unknown board handover action');
|
|
287
396
|
return status();
|
|
397
|
+
|
|
398
|
+
});
|
|
288
399
|
};
|
|
289
400
|
const answer = async line => {
|
|
401
|
+
return measureWork("door", "board-owner.command", async () => {
|
|
290
402
|
let request;
|
|
291
403
|
try { request = JSON.parse(line); } catch { /* generation answers malformed input */ }
|
|
292
404
|
if (request?.v === 1 && Array.isArray(request.argv) && request.argv[0] === 'handover') {
|
|
293
405
|
const parsed = parse(request.argv);
|
|
294
406
|
if (parsed.action === 'status') return { code: 0, stdout: JSON.stringify(status()) + '\n', stderr: '' };
|
|
295
407
|
if (transition) return failed(new Error('a board handover is already in progress'));
|
|
296
|
-
transition = control(parsed);
|
|
408
|
+
transition = control(parsed, request.cwd);
|
|
297
409
|
try { return { code: 0, stdout: JSON.stringify(await transition) + '\n', stderr: '' }; }
|
|
298
410
|
catch (error) { return failed(error); }
|
|
299
411
|
finally { transition = null; changed(); }
|
|
@@ -316,34 +428,57 @@ export async function serveBoardOwner(root, args) {
|
|
|
316
428
|
if (!serving.silenceSaid) { serving.silenceSaid = true; log(`generation ${serving.id} (pid ${serving.process.pid}) did not answer a ${verb} within ${READ_MS / 1000} s; its last message came ${quiet} s before`); }
|
|
317
429
|
return failed(new Error(`the board's serving generation (pid ${serving.process.pid}) did not answer this ${verb} within ${READ_MS / 1000} s (its last message to the board owner came ${quiet} s before); nothing was read: ask again`));
|
|
318
430
|
});
|
|
431
|
+
|
|
432
|
+
});
|
|
319
433
|
};
|
|
320
434
|
let closeDoor;
|
|
435
|
+
let failure, hadFailure = false;
|
|
321
436
|
const stop = () => {
|
|
322
437
|
if (stopping) return;
|
|
323
438
|
stopping = true; changed();
|
|
324
|
-
for (const generation of generations) if (!generation.ended) generation.process.kill('SIGTERM');
|
|
439
|
+
for (const generation of generations) if (!generation.ended) stopWorkChild(generation.process, () => generation.process.kill('SIGTERM'), { signal: 'SIGTERM' });
|
|
325
440
|
};
|
|
326
441
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);
|
|
327
442
|
try {
|
|
328
443
|
// Only this lock's holder publishes/removes the public socket, for its entire lifetime.
|
|
329
444
|
rmSync(boardOwnerSocket(root), { force: true });
|
|
330
445
|
closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer, log, readListings: false });
|
|
331
|
-
const
|
|
446
|
+
const selected = { entry, native: process.env.SUPERCODE_BIN, candidate: process.env.SUPERCODE_SOURCE_CANDIDATE || null, release: process.env.SUPERCODE_SOURCE_CANDIDATE ? null : retainedReceipt(entry) };
|
|
447
|
+
const reservation = await readBoardSourceReservation(root);
|
|
448
|
+
const first = start(reservation?.selected ?? selected);
|
|
332
449
|
await first.wait('prepared');
|
|
450
|
+
if (reservation?.phase === 'activated') pending = { id: reservation.id, candidate: first,
|
|
451
|
+
predecessor: null, before: reservation.before, older: null, phase: 'activated' };
|
|
333
452
|
await activate(first);
|
|
453
|
+
if (reservation?.phase === 'prepared') await prepare(reservation.id, reservation.target);
|
|
334
454
|
if (typeof process.send === 'function' && Number(process.env.SUPERCODE_BOARD_START_OWNER) === process.ppid)
|
|
335
455
|
process.send({ type: 'board-owner-ready', owner: process.pid });
|
|
336
|
-
if (
|
|
456
|
+
if (pidfile) writeFileSync(pidfile, `${process.pid}\n`);
|
|
337
457
|
while (!stopping) await new Promise(done => waiting.add(done));
|
|
458
|
+
} catch (error) {
|
|
459
|
+
hadFailure = true; failure = error; throw error;
|
|
338
460
|
} finally {
|
|
339
|
-
|
|
340
|
-
await
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
461
|
+
const errors = [];
|
|
462
|
+
const attempt = async work => { try { await work(); } catch (error) { errors.push(error); } };
|
|
463
|
+
await attempt(() => stop());
|
|
464
|
+
for (const result of await Promise.allSettled([...generations].map(generation => terminate(generation, 'the board owner stops'))))
|
|
465
|
+
if (result.status === 'rejected') errors.push(result.reason);
|
|
466
|
+
await attempt(() => closeDoor?.());
|
|
467
|
+
await attempt(() => rmSync(boardOwnerSocket(root), { force: true }));
|
|
468
|
+
await attempt(() => {
|
|
469
|
+
try { if (JSON.parse(readFileSync(recordPath, 'utf8')).owner === process.pid) {
|
|
470
|
+
if (lastRestingReader || unended.size) record(null, true);
|
|
471
|
+
else rmSync(recordPath);
|
|
472
|
+
} }
|
|
473
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
474
|
+
});
|
|
475
|
+
// Releasing the lock is attempted even when the public door failed to close. Join both owned exits before
|
|
476
|
+
// the final checkpoint, so their known completions and durations cannot remain in flight in a closed row.
|
|
477
|
+
await attempt(() => lock.release());
|
|
345
478
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop);
|
|
346
|
-
if (
|
|
479
|
+
if (pidfile) await attempt(() => rmSync(pidfile, { force: true }));
|
|
480
|
+
await closeWorkLedger();
|
|
481
|
+
if (errors.length) throw new AggregateError([...(hadFailure ? [failure] : []), ...errors], 'board owner shutdown failed', { cause: hadFailure ? failure : errors[0] });
|
|
347
482
|
}
|
|
348
483
|
return 0;
|
|
349
484
|
}
|
package/board/git-document.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { measureSyncChild } from '@volter/supercode-harness-sdk/work-ledger';
|
|
2
|
+
const execFileSync = (...args) => measureSyncChild(() => workLedgerNativeExecFileSync(...args));
|
|
1
3
|
// THE BOARD DOCUMENT IN GIT (company RFC 0025 decision 3; ADR 0001 points 2 and 3): when the board's document lives in a
|
|
2
4
|
// git checkout and the board says so (`"commit": true` in its ztrack.json), each write the manager does not make (its
|
|
3
5
|
// `"manager"`) is committed with its doer as author and pushed, after the checkout has taken what others pushed. People edit the document both here
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
// and the push error live in a linked worktree's own git dir) are read from `.git` itself, so a board open starts no
|
|
31
33
|
// process. A write does start its commit's git processes, beside the loop (A41).
|
|
32
34
|
import { current } from './call.mjs';
|
|
33
|
-
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
35
|
+
import { execFile, execFileSync as workLedgerNativeExecFileSync, spawn } from 'node:child_process';
|
|
34
36
|
import { readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
37
|
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
36
38
|
|
package/board/github-rest.mjs
CHANGED
|
@@ -35,12 +35,23 @@ function connection() {
|
|
|
35
35
|
pid INTEGER NOT NULL, caller TEXT NOT NULL, purpose TEXT NOT NULL, method TEXT NOT NULL,
|
|
36
36
|
endpoint TEXT NOT NULL, code TEXT NOT NULL, reason TEXT NOT NULL);
|
|
37
37
|
CREATE INDEX IF NOT EXISTS github_refusals_window ON github_refusals(started_at,purpose);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS github_source_facts (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
38
39
|
CREATE TABLE IF NOT EXISTS github_observation_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);`);
|
|
39
40
|
db.prepare("INSERT OR IGNORE INTO github_observation_meta(key,value) VALUES('refusals_since',?)").run(Date.now());
|
|
40
41
|
connections.set(path, db);
|
|
41
42
|
return db;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// Source facts have no discovery TTL. Merged heads are immutable; branch watermarks
|
|
46
|
+
// retain complete seeded history and are advanced only after a successful incremental read.
|
|
47
|
+
export function githubSourceFact(identity, value) {
|
|
48
|
+
const key = createHash('sha256').update(identity).digest('hex'), db = connection();
|
|
49
|
+
if (value !== undefined) db.prepare(`INSERT INTO github_source_facts(key,value) VALUES(?,?)
|
|
50
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, JSON.stringify(value));
|
|
51
|
+
const row = db.prepare('SELECT value FROM github_source_facts WHERE key=?').get(key);
|
|
52
|
+
return row ? JSON.parse(row.value) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
44
55
|
const discoveryReads = new Map();
|
|
45
56
|
const discoveryPending = () => Object.assign(new Error('GitHub discovery is already being read by another process; no duplicate read was started'), { code: 'github_discovery_pending', deferred: true });
|
|
46
57
|
/** Advisory observations survive command/owner restarts. Fresh transition reads never consume a cached value,
|
|
@@ -60,6 +71,12 @@ export function githubDiscoveryHeld(identity) {
|
|
|
60
71
|
return held && held.until_at > Date.now() && held.value != null ? JSON.parse(held.value) : null;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
/** When the observation kept for `identity` was last read (ms), or 0; no claim, no wire attempt. */
|
|
75
|
+
export function githubDiscoveryObservedAt(identity) {
|
|
76
|
+
const key = createHash('sha256').update(identity).digest('hex');
|
|
77
|
+
return Number(connection().prepare('SELECT observed_at FROM github_discovery WHERE key=?').get(key)?.observed_at ?? 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlOf = () => ttl, cacheErrors = true } = {}) {
|
|
64
81
|
const key = createHash('sha256').update(identity).digest('hex');
|
|
65
82
|
const localKey = `${pathOf()}\0${key}`;
|
|
@@ -111,7 +128,7 @@ export async function githubDiscovery(identity, ttl, read, { fresh = false, ttlO
|
|
|
111
128
|
}
|
|
112
129
|
return pending;
|
|
113
130
|
}
|
|
114
|
-
const refusal = (message) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET' });
|
|
131
|
+
const refusal = (message, retry_at) => Object.assign(new Error(message), { code: 'GITHUB_BUDGET', deferred: true, retry_at });
|
|
115
132
|
const integer = (value) => value != null && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
116
133
|
function endpointOf(url) {
|
|
117
134
|
const safe = new URL(url); safe.username = ''; safe.password = '';
|
|
@@ -133,14 +150,19 @@ async function issue(url, options, { purpose }) {
|
|
|
133
150
|
db.exec('BEGIN IMMEDIATE');
|
|
134
151
|
try {
|
|
135
152
|
const blocked = db.prepare('SELECT until_at,reason FROM github_cooldown WHERE credential=?').get(credential);
|
|
136
|
-
if (blocked?.until_at > at) throw refusal(`GitHub REST deferred until ${new Date(blocked.until_at).toISOString()}: ${blocked.reason}`);
|
|
137
153
|
const count = db.prepare(`SELECT COUNT(*) total, COALESCE(SUM(purpose='discovery'),0) discovery
|
|
138
154
|
FROM github_attempts WHERE started_at>?`).get(at - HOUR);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
// Expire enough attempts to get below *every* applicable limit. MIN is wrong after an
|
|
156
|
+
// over-limit retained window, and the discovery and total counters may free at different times.
|
|
157
|
+
let retryAt = blocked?.until_at > at ? blocked.until_at : 0;
|
|
158
|
+
for (const [kind, used, limit] of [['total', count.total, GITHUB_HOURLY_ATTEMPTS],
|
|
159
|
+
...(purpose === 'discovery' ? [['discovery', count.discovery, GITHUB_DISCOVERY_ATTEMPTS]] : [])]) {
|
|
160
|
+
if (used < limit) continue;
|
|
161
|
+
const expiry = db.prepare(`SELECT started_at FROM github_attempts WHERE started_at>?
|
|
162
|
+
AND (?='total' OR purpose='discovery') ORDER BY started_at LIMIT 1 OFFSET ?`).get(at - HOUR, kind, used - limit);
|
|
163
|
+
retryAt = Math.max(retryAt, expiry.started_at + HOUR + 1);
|
|
143
164
|
}
|
|
165
|
+
if (retryAt) throw refusal(`GitHub ${purpose} deferred by this home's REST budget/provider hold (${count.total}/${GITHUB_HOURLY_ATTEMPTS} total, ${count.discovery}/${GITHUB_DISCOVERY_ATTEMPTS} discovery in the last hour); earliest eligibility ${new Date(retryAt).toISOString()}, subject to admission recheck${blocked?.until_at > at ? `: ${blocked.reason}` : ''}`, retryAt);
|
|
144
166
|
db.prepare('INSERT INTO github_attempts(id,credential,started_at,machine,pid,caller,purpose,method,endpoint) VALUES(?,?,?,?,?,?,?,?,?)')
|
|
145
167
|
.run(id, credential, at, hostname(), process.pid, caller, purpose, method, endpoint);
|
|
146
168
|
db.exec('COMMIT');
|
|
@@ -169,7 +191,7 @@ async function issue(url, options, { purpose }) {
|
|
|
169
191
|
(response?.status === 429 || response?.status === 403) && retry != null ? Date.now() + retry * 1000 : 0,
|
|
170
192
|
remaining === 0 && reset == null ? Date.now() + 60_000 : 0);
|
|
171
193
|
if (until > Date.now()) db.prepare(`INSERT INTO github_cooldown(credential,until_at,reason) VALUES(?,?,?)
|
|
172
|
-
ON CONFLICT(credential) DO UPDATE SET until_at=MAX(until_at,excluded.until_at)
|
|
194
|
+
ON CONFLICT(credential) DO UPDATE SET reason=CASE WHEN excluded.until_at>until_at THEN excluded.reason ELSE reason END,until_at=MAX(until_at,excluded.until_at)`)
|
|
173
195
|
.run(credential, until, remaining === 0 ? `${resource ?? 'REST'} remaining=0 on ${endpoint}` : `HTTP ${response.status} Retry-After on ${endpoint}`);
|
|
174
196
|
}
|
|
175
197
|
}
|
package/board/incidents.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { latestEvent } from './queries.mjs';
|
|
1
2
|
// Service supervision retains evidence; only a manager/creator act releases dispatch.
|
|
2
3
|
import { event, json, now } from './store.mjs';
|
|
3
4
|
import { detachedCard, detachedSession } from './custody.mjs';
|
|
@@ -65,27 +66,14 @@ export function surfaceIncident(db, board, entry, id, name, evidence = {}, runId
|
|
|
65
66
|
/** Older ended runs are audited before eligibility: a completed run is not permission to repeat. */
|
|
66
67
|
export function auditEndedRuns(db, board, entry) {
|
|
67
68
|
if (recoversRuns(entry)) {
|
|
68
|
-
//
|
|
69
|
-
// failed-before-live fallback as a newly supervised resume. An unknown
|
|
70
|
-
// reading, a mere ended row or a once-live resume does not qualify.
|
|
71
|
-
for (const row of db.prepare(`SELECT r.id,r.metadata FROM task_runs r JOIN tasks t ON t.id=r.task_id
|
|
72
|
-
WHERE r.id=(SELECT MAX(id) FROM task_runs WHERE task_id=t.id) AND r.ended_at IS NOT NULL
|
|
73
|
-
AND t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived')`).all()) {
|
|
74
|
-
const metadata = json(row.metadata, {}), session = metadata.supercode;
|
|
75
|
-
if (detachedSession(db, session)) continue;
|
|
76
|
-
if (session?.mode !== 'resume' || session.seen_at || session.resume_failed_before_live) continue;
|
|
77
|
-
const exit = db.prepare("SELECT 1 FROM task_events WHERE run_id=? AND kind='run_incident' AND json_extract(payload,'$.observation')='exited' AND json_extract(payload,'$.evidence.code')=1").get(row.id);
|
|
78
|
-
if (!exit) continue;
|
|
79
|
-
metadata.supercode = { ...session, resumable: false, resume_failed_before_live: true };
|
|
80
|
-
db.prepare('UPDATE task_runs SET metadata=? WHERE id=?').run(JSON.stringify(metadata), row.id);
|
|
81
|
-
}
|
|
69
|
+
// A failed native resume preserves its conversation. Historical failures grant no fresh-session permission.
|
|
82
70
|
// The older organization workflow automatically paused a clean native exit.
|
|
83
71
|
// Release only that exact platform outcome, never a person's later pause.
|
|
84
72
|
for (const task of db.prepare(`SELECT t.id,c.paused FROM tasks t JOIN supercode_cards c ON c.task_id=t.id
|
|
85
73
|
WHERE t.current_run_id IS NULL AND t.status NOT IN ('done','cancelled','archived') AND c.paused IS NOT NULL AND NOT COALESCE(c.archived,0)`).all()) {
|
|
86
74
|
const pause = json(task.paused, null);
|
|
87
75
|
if (detachedCard(db, task.id)) continue;
|
|
88
|
-
const last = db.
|
|
76
|
+
const last = latestEvent(db, task.id, ['closed_by_hand', 'paused', 'resumed'], 'kind,payload');
|
|
89
77
|
const ended = json(last?.payload, {});
|
|
90
78
|
// The machine may have moved since this run. Match the retained board
|
|
91
79
|
// actor's exact label, never a user's actor or just the reason text.
|
|
@@ -101,10 +89,10 @@ export function auditEndedRuns(db, board, entry) {
|
|
|
101
89
|
WHERE t.current_run_id IS NULL AND r.ended_at IS NOT NULL AND t.status IN ('running','review')`).all()) {
|
|
102
90
|
const session = json(task.run_metadata, {})?.supercode;
|
|
103
91
|
if (detachedCard(db, task.id) || detachedSession(db, session)) continue;
|
|
104
|
-
if (!session?.monitor_held) continue;
|
|
92
|
+
if (!session?.monitor_held || session.continuation_required) continue;
|
|
105
93
|
const state = db.prepare('SELECT paused,blocked_by,archived FROM supercode_cards WHERE task_id=?').get(task.id);
|
|
106
94
|
if (json(state?.paused, null) || json(state?.blocked_by, [])?.length || state?.archived) continue;
|
|
107
|
-
const last = db.
|
|
95
|
+
const last = latestEvent(db, task.id, ['dispatch_held','dispatch_released','session_adopted'], 'kind,payload');
|
|
108
96
|
const hold = json(last?.payload, {});
|
|
109
97
|
if (last?.kind !== 'dispatch_held' || hold.run !== task.run_id || hold.incident !== session.incident) continue;
|
|
110
98
|
requestSessionClose(db, task.id, { runId: task.run_id });
|