@volter/supercode-orchestrator 0.5.215 → 0.5.216
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 +71 -9
- package/activation.mjs +47 -11
- package/adapters/chat-sdk.mjs +82 -15
- package/adapters/rh2.mjs +11 -7
- package/agents.mjs +1 -5
- package/bin/agent-channel.mjs +33 -177
- package/bin/connection-runner.mjs +3 -17
- package/bin/orchestrator.mjs +12 -7
- package/bin/runtime.mjs +74 -51
- package/board/call.mjs +2 -1
- package/board/ci.mjs +55 -28
- package/board/cli.mjs +117 -13
- package/board/client.mjs +9 -0
- package/board/dispatch.mjs +45 -15
- package/board/engine.mjs +2 -2
- package/board/fence.mjs +2 -0
- package/board/generation-owner.mjs +293 -0
- package/board/generation.mjs +49 -0
- package/board/listing.mjs +17 -5
- package/board/owner-door-worker.mjs +22 -7
- package/board/owner-door.mjs +2 -2
- package/board/owner-files.mjs +1 -1
- package/board/published.mjs +12 -7
- package/board/reply.mjs +2 -2
- package/board/session-work.mjs +85 -15
- package/connection.mjs +14 -20
- package/cron.mjs +57 -4
- package/daemon-control.d.ts +39 -0
- package/daemon-control.mjs +275 -0
- package/hermes-locks.mjs +13 -3
- package/index.d.ts +9 -4
- package/installed-apps.mjs +35 -14
- package/kanban-worker.mjs +4 -4
- package/lease.mjs +1 -1
- package/loop.mjs +198 -47
- package/mcp/mount.mjs +6 -5
- package/orchestration.mjs +11 -17
- package/package.json +9 -6
- package/reducer.mjs +31 -6
- package/source-lock.mjs +15 -24
- package/webhook.mjs +65 -12
package/board/dispatch.mjs
CHANGED
|
@@ -578,10 +578,16 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
578
578
|
const row = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row;
|
|
579
579
|
native = { turn: row?.activity?.turn };
|
|
580
580
|
} catch { /* an unread turn stays unknown */ }
|
|
581
|
-
|
|
581
|
+
// the session's machine as this round read it, so the card can say why a session reads unknown (executionOf)
|
|
582
|
+
// (a round that read no fleet, on a board with no pane lane, says nothing of any machine)
|
|
583
|
+
const where = session.surface !== 'headless' && session.machine && fleet.errors instanceof Map
|
|
584
|
+
? fleet.online.has(session.machine) ? { machine_reading: 'online' }
|
|
585
|
+
: { machine_reading: fleet.offline?.has(normalMachine(session.machine)) ? 'offline' : 'unknown', machine_error: String(fleet.errors.get(normalMachine(session.machine)) ?? fleet.error ?? '').slice(0, 300) || null }
|
|
586
|
+
: {};
|
|
587
|
+
const reading = { alive: alive ?? null, turn: alive === true ? native?.turn ?? null : null, observed_at: t, ...where };
|
|
582
588
|
const prior = session.presence_observation;
|
|
583
589
|
// Keep state changes immediately and renew unchanged readings once a minute.
|
|
584
|
-
if (!prior || prior.alive !== reading.alive || prior.turn !== reading.turn || t - prior.observed_at >= 60) {
|
|
590
|
+
if (!prior || prior.alive !== reading.alive || prior.turn !== reading.turn || prior.machine_reading !== reading.machine_reading || t - prior.observed_at >= 60) {
|
|
585
591
|
setRunSession(db, runRow.id, { presence_observation: reading });
|
|
586
592
|
session = { ...session, presence_observation: reading };
|
|
587
593
|
}
|
|
@@ -3592,6 +3598,8 @@ export { boardLabel };
|
|
|
3592
3598
|
// board, card start, board message or notice. What it leaves unfiled is not a failure; the next dispatcher files it.
|
|
3593
3599
|
let stopping = false;
|
|
3594
3600
|
export function stopDispatching() { stopping = true; }
|
|
3601
|
+
// Only the stable owner's parked generation is resumed; it takes a fresh fencing epoch first.
|
|
3602
|
+
export function resumeDispatching() { stopping = false; }
|
|
3595
3603
|
|
|
3596
3604
|
/** A board file's fingerprint: a corrupt board is skipped until the file changes (Hermes retries it then). */
|
|
3597
3605
|
const fingerprint = (path) => { try { const st = statSync(path); return `${st.size}:${st.mtimeMs}`; } catch { return 'missing'; } };
|
|
@@ -3754,7 +3762,8 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3754
3762
|
stalls.push({ id, key: `no_work_branch:${lastReviewEvent(board, id)}`, why: `its review waits: ${payload.waits}` });
|
|
3755
3763
|
payload = null; continue;
|
|
3756
3764
|
}
|
|
3757
|
-
|
|
3765
|
+
// A PR of the arc proposed to a branch other than main is refused below, whatever the others' verdicts.
|
|
3766
|
+
if (!payload.no_code && !payload.stacked?.length && !await reviewDue(board, id, payload.pr.map((url, index) => ({ url, head: payload.pr_head[index] })))) {
|
|
3758
3767
|
const why = `every open PR head (${payload.pr.join(', ')}) has a verdict, carries the source tree its verdict judged, or predates it; push a commit that changes the source, or close the tasks that own the last verdict's findings`;
|
|
3759
3768
|
report.queued.push([id, `review waits: ${why}`]);
|
|
3760
3769
|
// After a pass the arc's own session merges and asks for done; any other verdict nothing answered waits on an act
|
|
@@ -3772,12 +3781,20 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3772
3781
|
continue;
|
|
3773
3782
|
}
|
|
3774
3783
|
// A stable PR-property or local refusal must not spend a full fresh REST scan every round. Advisory
|
|
3775
|
-
// discovery is sufficient to refuse, never to authorize; the scan below still owns that.
|
|
3776
|
-
|
|
3784
|
+
// discovery is sufficient to refuse, never to authorize; the scan below still owns that. Nor is it enough to
|
|
3785
|
+
// tell: its PR heads, bases and states come from the shared discovery cache (up to five minutes old), so a push
|
|
3786
|
+
// or retarget since may already have cleared what it refuses. It refuses without the fresh scan only when it is
|
|
3787
|
+
// the wait already told; a new one goes on to the fresh read below, which tells it only if it still stands.
|
|
3788
|
+
if (!payload.no_code) {
|
|
3789
|
+
try { await openReviewPullRequests(board, id, { checkWorkspaces: true }); }
|
|
3790
|
+
catch (error) {
|
|
3791
|
+
if (error?.code !== 'review_refused' || view(board, (db) => toldReviewWait(db, id)) === refusalKey(board, id, error)) throw error;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3777
3794
|
if (!dry) await refreshCardInputs(board, id, { commits: true });
|
|
3778
3795
|
// Cached draft/closed exclusions are useful while waiting, but cannot define a committed review's set.
|
|
3779
3796
|
const observed = await withFreshReviewReads(async () => {
|
|
3780
|
-
const candidate = await openReviewPullRequests(board, id, { fresh: true, repositories: payload.review_repositories ?? [] })
|
|
3797
|
+
const candidate = await openReviewPullRequests(board, id, { fresh: true, refuseStacked: true, repositories: payload.review_repositories ?? [] })
|
|
3781
3798
|
?? (noCodeReviewDue(board, id) ? await noCodeChange(board, id, { fresh: true }) : null);
|
|
3782
3799
|
const evidence = !candidate || candidate.waits ? null : candidate.no_code
|
|
3783
3800
|
? { required: false, ready: true, reason: null, phase: 'source', no_code: candidate.no_code }
|
|
@@ -3787,7 +3804,7 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3787
3804
|
payload = observed.candidate;
|
|
3788
3805
|
if (!payload || payload.waits) { report.queued.push([id, `review waits: ${payload?.waits ?? 'the current source set has no review candidate'}`]); payload = null; continue; }
|
|
3789
3806
|
const evidence = observed.evidence;
|
|
3790
|
-
if (!evidence.ready) throw new Error(evidence.reason);
|
|
3807
|
+
if (!evidence.ready) throw Object.assign(new Error(evidence.reason), { code: evidence.code, refusals: evidence.refusals });
|
|
3791
3808
|
// A dry run names the complete validated review a tick would request and writes nothing.
|
|
3792
3809
|
if (dry) { (report.would_review ??= []).push({ id, prs: payload.pr, heads: payload.pr_head, no_code: payload.no_code ?? null }); continue; }
|
|
3793
3810
|
const moved = tx(board, db => {
|
|
@@ -3804,7 +3821,14 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3804
3821
|
effects.push(...moved.effects);
|
|
3805
3822
|
(report.review_requested ??= []).push({ id, prs: payload.pr, heads: payload.pr_head });
|
|
3806
3823
|
}
|
|
3807
|
-
} catch (error) {
|
|
3824
|
+
} catch (error) {
|
|
3825
|
+
report.queued.push([id, `PR review waits${payload?.pr?.length ? ` (${payload.pr.join(', ')})` : ''}: ${error.message}`]);
|
|
3826
|
+
// A refusal only the arc's act clears (a PR proposed to a branch other than main, a draft, an unpushed or
|
|
3827
|
+
// uncommitted checkout) is a review wait like any other: every task is closed, so no session of the arc is told
|
|
3828
|
+
// by its tasks, and a refusal only logged left VO-3602 t_918cc86e unreviewed for its whole idle session. Its key
|
|
3829
|
+
// is its refused PRs and kinds alone (refusalKey); the detail is in `why`.
|
|
3830
|
+
if (error?.code === 'review_refused') stalls.push({ id, key: refusalKey(board, id, error), why: `its review waits: ${error.message} Every task is closed, so no session of the arc is due to act on it.` });
|
|
3831
|
+
}
|
|
3808
3832
|
}
|
|
3809
3833
|
if (!dry) surfaceReviewStalls(board, entry, stalls, settled, report);
|
|
3810
3834
|
}
|
|
@@ -3813,6 +3837,18 @@ export async function activatePullRequestReviews(board, entry, report, effects,
|
|
|
3813
3837
|
* carries it, so the wait a new verdict or request leaves is a new wait. */
|
|
3814
3838
|
const lastReviewEvent = (board, id) => board.db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind IN ('review_requested', 'changes_requested', 'review_pass', 'review_voided')").get(id)?.id ?? 0;
|
|
3815
3839
|
|
|
3840
|
+
/** A review refusal's wait key: the arc's last review event and the sorted kinds and PRs it refused (ci.mjs refusalOf),
|
|
3841
|
+
* never a count or a path, so the same refusal is the same wait however its detail or read order changes. */
|
|
3842
|
+
const refusalKey = (board, id, error) => `refused:${lastReviewEvent(board, id)}:${(error.refusals ?? []).map((r) => `${r.kind}:${r.url}`).join(' ')}`;
|
|
3843
|
+
|
|
3844
|
+
/** The key of the card's told review wait not cleared since, or null. */
|
|
3845
|
+
function toldReviewWait(db, id) {
|
|
3846
|
+
const last = db.prepare("SELECT id, json_extract(payload, '$.review_wait') AS key FROM task_events WHERE task_id = ? AND kind = 'board_message' AND json_extract(payload, '$.review_wait') IS NOT NULL ORDER BY id DESC LIMIT 1").get(id);
|
|
3847
|
+
if (!last) return null;
|
|
3848
|
+
const cleared = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_wait_cleared'").get(id)?.id ?? 0;
|
|
3849
|
+
return cleared > last.id ? null : last.key;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3816
3852
|
/** At most this many review waits are told per round; the rest are told in the rounds after, in candidate order. */
|
|
3817
3853
|
const REVIEW_WAITS_TOLD_PER_ROUND = 8;
|
|
3818
3854
|
/**
|
|
@@ -3832,13 +3868,7 @@ const REVIEW_WAITS_TOLD_PER_ROUND = 8;
|
|
|
3832
3868
|
function surfaceReviewStalls(board, entry, stalls, settled, report) {
|
|
3833
3869
|
const to = [].concat(entry.params.managers ?? []).find(Boolean);
|
|
3834
3870
|
if (!to || (!stalls.length && !settled.length)) return;
|
|
3835
|
-
|
|
3836
|
-
const told = (db, id) => {
|
|
3837
|
-
const last = db.prepare("SELECT id, json_extract(payload, '$.review_wait') AS key FROM task_events WHERE task_id = ? AND kind = 'board_message' AND json_extract(payload, '$.review_wait') IS NOT NULL ORDER BY id DESC LIMIT 1").get(id);
|
|
3838
|
-
if (!last) return null;
|
|
3839
|
-
const cleared = db.prepare("SELECT MAX(id) AS id FROM task_events WHERE task_id = ? AND kind = 'review_wait_cleared'").get(id)?.id ?? 0;
|
|
3840
|
-
return cleared > last.id ? null : last.key;
|
|
3841
|
-
};
|
|
3871
|
+
const told = toldReviewWait;
|
|
3842
3872
|
// read first: a round with nothing to tell or clear opens no write
|
|
3843
3873
|
const plan = view(board, (db) => ({
|
|
3844
3874
|
clear: settled.filter((id) => told(db, id)),
|
package/board/engine.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { allocateReadableId } from './readable-id.mjs';
|
|
|
10
10
|
// observes or sends: the dispatcher (`dispatch.mjs`) does, and records what it did through these.
|
|
11
11
|
import { current } from './call.mjs';
|
|
12
12
|
import { detachedCard, detachedSession, personCustody, sessionAddress, sessionOperation } from './custody.mjs';
|
|
13
|
-
import { executionOf } from './session-work.mjs';
|
|
13
|
+
import { executionOf, readingContext } from './session-work.mjs';
|
|
14
14
|
import { cardState, cardStateSql, holdDescription } from './card-state.mjs';
|
|
15
15
|
import { spawn, execFile } from 'node:child_process';
|
|
16
16
|
import { existsSync, rmSync } from 'node:fs';
|
|
@@ -927,7 +927,7 @@ export function listCards(db, { status = null, assignee = null, machine = null,
|
|
|
927
927
|
const rows = all(db, `SELECT t.*, n.number, c.machine, c.subtask_of, c.stage, c.tags, c.paused, c.blocked_by FROM tasks t LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN ${CARD_NUMBERS} n ON n.task_id = t.id
|
|
928
928
|
WHERE (? IS NULL OR ${stateSql} = ? OR (? NOT IN ('paused','blocked') AND c.stage = ?)) AND (? IS NULL OR t.assignee = ?) AND (? IS NULL OR c.machine = ?) AND ${CARD_VISIBILITY_SQL}
|
|
929
929
|
ORDER BY t.priority DESC, t.created_at ASC`, status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0);
|
|
930
|
-
return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null) }, { nativeHolds }));
|
|
930
|
+
return rows.map((r) => cardState({ ...r, skills: json(r.skills, []) ?? [], execution: executionOf(r.current_run_id ? one(db, 'SELECT id,metadata,ended_at FROM task_runs WHERE id=?', r.current_run_id) : null, readingContext(db, r.id, r.current_run_id)) }, { nativeHolds }));
|
|
931
931
|
}
|
|
932
932
|
|
|
933
933
|
export function showCard(db, id, options = {}) {
|
package/board/fence.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { renameSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { boardHome } from '@volter/teams/board-owner';
|
|
12
|
+
import { assertGenerationWriter } from './generation.mjs';
|
|
12
13
|
|
|
13
14
|
let mine = null;
|
|
14
15
|
const STATE = 'CREATE TABLE IF NOT EXISTS supercode_board_state (key TEXT PRIMARY KEY, value TEXT)';
|
|
@@ -32,6 +33,7 @@ export function takeEpoch(root, boards) {
|
|
|
32
33
|
|
|
33
34
|
/** At a write transaction's start: in the owner, store its epoch unless a newer one is stored, and exit when it is. */
|
|
34
35
|
export function fenced(db) {
|
|
36
|
+
assertGenerationWriter();
|
|
35
37
|
if (mine === null) return;
|
|
36
38
|
db.exec(STATE);
|
|
37
39
|
const r = db.prepare(`INSERT INTO supercode_board_state (key, value) VALUES ('owner_epoch', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// The stable board owner: one OS lock and public door, with replaceable, fenced dispatcher generations behind it.
|
|
2
|
+
// A request already sent to a generation is never replayed. Only unsent requests wait at the handover boundary.
|
|
3
|
+
import { readSourceCandidate } from '@volter/supercode-teams/source-selection';
|
|
4
|
+
import { sourceLockPath } from '@volter/supercode-teams/source-lock';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { boardHome, boardOwnerSocket } from '@volter/teams/board-owner';
|
|
11
|
+
import { retainedReceipt } from '@volter/supercode-teams/release-selection';
|
|
12
|
+
import { sourceTransaction } from '@volter/supercode-teams/serving-source-transaction';
|
|
13
|
+
import { holdLock } from './lock-holder.mjs';
|
|
14
|
+
import { openOwnerDoor } from './owner-door.mjs';
|
|
15
|
+
import { holdOwnerHealth } from './owner-health.mjs';
|
|
16
|
+
import { holdWorkflow } from './workflow.mjs';
|
|
17
|
+
import { knowLocalMachine, localMachine } from './dispatch.mjs';
|
|
18
|
+
import { parse } from './args.mjs';
|
|
19
|
+
|
|
20
|
+
const unknown = () => ({ code: 1, unanswered: true, stdout: '', stderr: 'the board generation ended after taking this request; its outcome is unknown\n' });
|
|
21
|
+
const failed = error => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error.message}\n` });
|
|
22
|
+
|
|
23
|
+
export async function serveBoardOwner(root, args) {
|
|
24
|
+
root = boardHome(root);
|
|
25
|
+
mkdirSync(root, { recursive: true });
|
|
26
|
+
const log = line => process.stdout.write(`${new Date().toISOString()} board owner: ${line}\n`);
|
|
27
|
+
let stopping = false, active = null, previous = null, transition = null, routing = null, pending = null, recoveryFailure = null;
|
|
28
|
+
const generations = new Set(), waiting = new Set();
|
|
29
|
+
const recordPath = join(root, 'board-generation.json');
|
|
30
|
+
const record = child => {
|
|
31
|
+
const temporary = `${recordPath}.${randomUUID()}.tmp`;
|
|
32
|
+
writeFileSync(temporary, JSON.stringify({ version: 1, owner: process.pid, active: child?.process.pid ?? null,
|
|
33
|
+
selected: child?.selected ?? null, generation: child?.id ?? null, previous: previous?.process.pid ?? null, at_ms: Date.now() }) + '\n', { mode: 0o600 });
|
|
34
|
+
renameSync(temporary, recordPath);
|
|
35
|
+
};
|
|
36
|
+
const changed = () => { for (const done of waiting) done(); waiting.clear(); };
|
|
37
|
+
const lock = await holdLock(join(root, 'board.lock'), { onLost: error => { log(error.message); process.exit(1); } });
|
|
38
|
+
await knowLocalMachine();
|
|
39
|
+
const entry = resolve(process.env.SUPERCODE_ORCHESTRATOR_ENTRY || fileURLToPath(new URL('../bin/orchestrator.mjs', import.meta.url)));
|
|
40
|
+
const start = selected => {
|
|
41
|
+
const generationId = randomUUID();
|
|
42
|
+
const env = { ...process.env, SUPERCODE_BOARD_GENERATION_OWNER: String(process.pid), SUPERCODE_BOARD_GENERATION_ID: generationId,
|
|
43
|
+
SUPERCODE_BOARD_GENERATION_MACHINE: localMachine(), SUPERCODE_ORCHESTRATOR_ENTRY: selected.entry, SUPERCODE_BIN: selected.native };
|
|
44
|
+
delete env.SUPERCODE_SOURCE_CANDIDATE; delete env.SUPERCODE_RELEASE_RECEIPT; delete env.SUPERCODE_RELEASE_VERSION;
|
|
45
|
+
if (selected.candidate) env.SUPERCODE_SOURCE_CANDIDATE = selected.candidate;
|
|
46
|
+
if (selected.release) env.SUPERCODE_RELEASE_RECEIPT = selected.release;
|
|
47
|
+
const runtime = selected.candidate ? readSourceCandidate(selected.candidate, sourceLockPath()).node : process.execPath;
|
|
48
|
+
env.SUPERCODE_NODE_BIN = runtime;
|
|
49
|
+
const processChild = spawn(runtime, [selected.entry, 'workflow', 'serve', '--root', root,
|
|
50
|
+
...(args.interval ? ['--interval', String(args.interval)] : []), ...(args.max ? ['--max', String(args.max)] : []),
|
|
51
|
+
...(args['failure-limit'] ? ['--failure-limit', String(args['failure-limit'])] : []), ...(args.verbose ? ['--verbose'] : [])], {
|
|
52
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'], serialization: 'advanced',
|
|
53
|
+
env,
|
|
54
|
+
});
|
|
55
|
+
const generation = { process: processChild, selected, id: generationId, calls: new Map(), events: new Map(), ended: false, health: null, workflows: new Map() };
|
|
56
|
+
generation.exited = new Promise(done => { processChild.once('exit', done); processChild.once('error', done); });
|
|
57
|
+
generations.add(generation);
|
|
58
|
+
const tell = frame => { if (processChild.connected) processChild.send(frame); else throw new Error('board generation disconnected'); };
|
|
59
|
+
generation.tell = tell;
|
|
60
|
+
generation.wait = (event, ms = 8000) => new Promise((done, reject) => {
|
|
61
|
+
if (generation.ended) return reject(new Error('board generation ended'));
|
|
62
|
+
if (generation.events.has(event)) return done(generation.events.get(event));
|
|
63
|
+
const waiter = { done, reject, timer: null }, expires = Date.now() + 90000;
|
|
64
|
+
waiter.arm = budget => {
|
|
65
|
+
clearTimeout(waiter.timer);
|
|
66
|
+
waiter.timer = setTimeout(() => { generation.events.delete(event); reject(new Error(`board generation did not report ${event}; last preparation step: ${generation.startupStep ?? 'entry loading'}; predecessor retained`)); }, Math.min(budget, Math.max(1, expires - Date.now())));
|
|
67
|
+
};
|
|
68
|
+
waiter.arm(ms); generation.events.set(event, waiter);
|
|
69
|
+
});
|
|
70
|
+
generation.ask = (line, timeout = null) => new Promise(done => {
|
|
71
|
+
const id = randomUUID();
|
|
72
|
+
let timer;
|
|
73
|
+
const finish = answer => { clearTimeout(timer); generation.calls.delete(id); done(answer); };
|
|
74
|
+
if (timeout) timer = setTimeout(() => finish(unknown()), timeout);
|
|
75
|
+
generation.calls.set(id, finish);
|
|
76
|
+
try { tell({ type: 'request', id, line }); }
|
|
77
|
+
catch { generation.calls.delete(id); done(unknown()); }
|
|
78
|
+
});
|
|
79
|
+
const ended = () => {
|
|
80
|
+
if (generation.ended) return;
|
|
81
|
+
generation.ended = true;
|
|
82
|
+
// IPC loss revokes the child but does not prove its process ended. End that exact owned child before recovery.
|
|
83
|
+
if (processChild.exitCode === null && processChild.signalCode === null) {
|
|
84
|
+
processChild.kill('SIGTERM');
|
|
85
|
+
const bound = setTimeout(() => processChild.kill('SIGKILL'), 2000);
|
|
86
|
+
generation.exited.finally(() => clearTimeout(bound));
|
|
87
|
+
}
|
|
88
|
+
generations.delete(generation);
|
|
89
|
+
for (const done of generation.calls.values()) done(unknown());
|
|
90
|
+
generation.calls.clear();
|
|
91
|
+
for (const value of generation.events.values()) if (value.reject) { clearTimeout(value.timer); value.reject(new Error('board generation ended')); }
|
|
92
|
+
generation.events.clear();
|
|
93
|
+
changed();
|
|
94
|
+
if (!stopping && routing === generation) {
|
|
95
|
+
routing = null;
|
|
96
|
+
// Only a parked predecessor can resume. A failed first generation leaves the public door up and names why.
|
|
97
|
+
if (pending) recoveryFailure = new Error('prepared board generation ended; the coordinating transaction must roll back');
|
|
98
|
+
if (!transition && !pending) {
|
|
99
|
+
transition = (async () => {
|
|
100
|
+
await generation.exited;
|
|
101
|
+
const replacement = start(generation.selected);
|
|
102
|
+
await replacement.wait('prepared');
|
|
103
|
+
return activate(replacement);
|
|
104
|
+
})().catch(error => { recoveryFailure = error; log(`generation recovery refused: ${error.message}`); }).finally(() => { transition = null; changed(); });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
processChild.on('message', frame => {
|
|
109
|
+
if (!frame || typeof frame.type !== 'string') return;
|
|
110
|
+
if (frame.type === 'startup-step') {
|
|
111
|
+
generation.startupStep = frame.step;
|
|
112
|
+
if (Number.isSafeInteger(frame.budget_ms) && frame.budget_ms >= 1 && frame.budget_ms <= 65000)
|
|
113
|
+
generation.events.get(frame.phase === 'serving' ? 'serving' : 'prepared')?.arm?.(frame.budget_ms);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (frame.type === 'answer') { generation.calls.get(frame.id)?.(frame.answer); generation.calls.delete(frame.id); changed(); return; }
|
|
117
|
+
if (frame.type === 'health') { generation.health = frame; if (routing === generation) holdOwnerHealth(frame.boards, frame.round); return; }
|
|
118
|
+
if (frame.type === 'workflow') { generation.workflows.set(frame.root, frame.entry); if (routing === generation) holdWorkflow(frame.root, frame.entry); return; }
|
|
119
|
+
const waiter = generation.events.get(frame.type);
|
|
120
|
+
if (waiter?.done) { clearTimeout(waiter.timer); waiter.done(frame); }
|
|
121
|
+
generation.events.set(frame.type, frame);
|
|
122
|
+
});
|
|
123
|
+
processChild.once('error', ended); processChild.once('exit', ended); processChild.once('disconnect', ended);
|
|
124
|
+
return generation;
|
|
125
|
+
};
|
|
126
|
+
const terminate = async generation => {
|
|
127
|
+
generation.process.kill('SIGTERM');
|
|
128
|
+
const timer = setTimeout(() => generation.process.kill('SIGKILL'), 2000);
|
|
129
|
+
try { await generation.exited; } finally { clearTimeout(timer); }
|
|
130
|
+
};
|
|
131
|
+
const activate = async generation => {
|
|
132
|
+
if (stopping || generation.ended) throw new Error('cannot activate an ended board generation');
|
|
133
|
+
record(generation);
|
|
134
|
+
generation.events.delete('serving');
|
|
135
|
+
generation.tell({ type: 'activate' });
|
|
136
|
+
const serving = await generation.wait('serving');
|
|
137
|
+
const read = async argv => {
|
|
138
|
+
const proof = await generation.ask(JSON.stringify({ v: 1, argv, cwd: root,
|
|
139
|
+
facts: { SUPERCODE_CONTEXT: process.env.SUPERCODE_CONTEXT || 'local' } }), 8000);
|
|
140
|
+
if (proof.code !== 0 || proof.unanswered) throw new Error(`successor ${argv[0]} readback failed: ${Array.isArray(proof.stderr) ? proof.stderr.join('') : proof.stderr || 'answer unknown'}; predecessor retained`);
|
|
141
|
+
return JSON.parse(Array.isArray(proof.stdout) ? proof.stdout.join('') : proof.stdout);
|
|
142
|
+
};
|
|
143
|
+
// A newly rendered home can have no initialized board, or several named boards. Read the actual inventory
|
|
144
|
+
// before asking each store for stats; an assumed default would refuse an otherwise serving empty home.
|
|
145
|
+
const boards = serving.boards;
|
|
146
|
+
if (!Array.isArray(boards)) throw new Error('successor board inventory was unreadable; predecessor retained');
|
|
147
|
+
await read(['declaration', '--json']);
|
|
148
|
+
for (const slug of boards) {
|
|
149
|
+
if (typeof slug !== 'string') throw new Error('successor board inventory has no slug; predecessor retained');
|
|
150
|
+
await read(['stats', '--json', '--board', slug]);
|
|
151
|
+
}
|
|
152
|
+
active = generation; routing = generation; recoveryFailure = null;
|
|
153
|
+
if (generation.health) holdOwnerHealth(generation.health.boards, generation.health.round);
|
|
154
|
+
for (const [at, value] of generation.workflows) holdWorkflow(at, value);
|
|
155
|
+
changed();
|
|
156
|
+
log(`generation ${generation.id} serves (pid ${generation.process.pid})`);
|
|
157
|
+
return generation;
|
|
158
|
+
};
|
|
159
|
+
const status = () => ({ version: 1, protocol: 1, owner: process.pid, state: routing ? 'serving' : 'transitioning',
|
|
160
|
+
generation: active?.id, pid: active?.process.pid, selected: active?.selected, previous: previous?.selected ?? null,
|
|
161
|
+
transaction: pending?.id ?? null, phase: pending?.phase ?? null, failure: recoveryFailure?.message ?? null });
|
|
162
|
+
const prepare = async (id, selected) => {
|
|
163
|
+
if (pending) {
|
|
164
|
+
if (pending.id === id) {
|
|
165
|
+
if (JSON.stringify(pending.candidate.selected) !== JSON.stringify(selected)) throw new Error('board transaction already names different inputs');
|
|
166
|
+
return status();
|
|
167
|
+
}
|
|
168
|
+
throw new Error('another board generation is prepared');
|
|
169
|
+
}
|
|
170
|
+
if (!active || active.ended) throw new Error('no serving predecessor can hand over this board');
|
|
171
|
+
sourceTransaction(id, selected, active.selected, 'prepare', process.env, 'board');
|
|
172
|
+
const candidate = start(selected);
|
|
173
|
+
try {
|
|
174
|
+
const proof = await candidate.wait('prepared'), held = active.events.get('prepared');
|
|
175
|
+
if (!proof.storage || proof.storage !== held?.storage) throw new Error('board storage contract differs; predecessor retained without opening a writer');
|
|
176
|
+
pending = { id, candidate, predecessor: active, older: previous, phase: 'prepared' };
|
|
177
|
+
return status();
|
|
178
|
+
} catch (error) { await terminate(candidate); throw error; }
|
|
179
|
+
};
|
|
180
|
+
const rollback = async () => {
|
|
181
|
+
const held = pending;
|
|
182
|
+
if (!held) return status();
|
|
183
|
+
await terminate(held.candidate);
|
|
184
|
+
previous = held.older;
|
|
185
|
+
if (held.phase !== 'prepared') {
|
|
186
|
+
routing = null;
|
|
187
|
+
// A timed-out park may still be executing. End that exact child before a fresh predecessor takes an epoch;
|
|
188
|
+
// an activate sent to a child still parking can otherwise race its final writer revocation.
|
|
189
|
+
await terminate(held.predecessor);
|
|
190
|
+
const replacement = start(held.predecessor.selected);
|
|
191
|
+
try { await replacement.wait('prepared'); await activate(replacement); }
|
|
192
|
+
catch (error) { await terminate(replacement); throw error; }
|
|
193
|
+
} else if (!held.predecessor.ended) { active = routing = held.predecessor; changed(); }
|
|
194
|
+
pending = null; record(active); return status();
|
|
195
|
+
};
|
|
196
|
+
const switchPrepared = async () => {
|
|
197
|
+
const held = pending;
|
|
198
|
+
if (held.phase === 'activated' && routing === held.candidate) return status();
|
|
199
|
+
if (held.phase !== 'prepared' || active !== held.predecessor) throw new Error('board preparation is no longer current');
|
|
200
|
+
try {
|
|
201
|
+
const requireCandidate = () => {
|
|
202
|
+
if (held.candidate.ended || !held.candidate.process.connected)
|
|
203
|
+
throw new Error('prepared board candidate ended before activation; predecessor retained');
|
|
204
|
+
};
|
|
205
|
+
requireCandidate();
|
|
206
|
+
routing = null;
|
|
207
|
+
let drainTimer;
|
|
208
|
+
try { await Promise.race([new Promise(done => {
|
|
209
|
+
const inspect = () => { if (!held.predecessor.calls.size || held.predecessor.ended) done(); else waiting.add(inspect); }; inspect();
|
|
210
|
+
}), new Promise((_, reject) => { drainTimer = setTimeout(() => reject(new Error('board requests did not drain; predecessor retained')), 8000); })]); }
|
|
211
|
+
finally { clearTimeout(drainTimer); }
|
|
212
|
+
requireCandidate();
|
|
213
|
+
held.phase = 'parking';
|
|
214
|
+
held.predecessor.events.delete('parked'); held.predecessor.tell({ type: 'park' });
|
|
215
|
+
await held.predecessor.wait('parked'); previous = held.predecessor;
|
|
216
|
+
await activate(held.candidate); held.phase = 'activated'; return status();
|
|
217
|
+
} catch (error) { await rollback(); throw error; }
|
|
218
|
+
};
|
|
219
|
+
const control = async parsed => {
|
|
220
|
+
const action = parsed.action ?? 'replace', id = parsed.transaction;
|
|
221
|
+
if (action === 'status') return status();
|
|
222
|
+
if (typeof id !== 'string' || !id) throw new Error('board handover needs --transaction');
|
|
223
|
+
if (action === 'prepare' || action === 'replace') {
|
|
224
|
+
if (!parsed.entry || !parsed.supercode) throw new Error('handover needs --entry and --supercode from one prepared machine release');
|
|
225
|
+
await prepare(id, { entry: resolve(parsed.entry), native: resolve(parsed.supercode), candidate: parsed.candidate ? resolve(parsed.candidate) : null, release: parsed.release ? resolve(parsed.release) : null });
|
|
226
|
+
} else if (pending?.id !== id) throw new Error('this board holds no matching handover transaction');
|
|
227
|
+
if (pending) sourceTransaction(id, pending.candidate.selected, pending.predecessor.selected, action === 'replace' ? 'activate' : action, process.env, 'board');
|
|
228
|
+
if (action === 'activate' || action === 'replace') await switchPrepared();
|
|
229
|
+
if (action === 'rollback' || action === 'abort') return rollback();
|
|
230
|
+
if (action === 'finish' || action === 'replace') {
|
|
231
|
+
sourceTransaction(id, pending.candidate.selected, pending.predecessor.selected, 'finish', process.env, 'board');
|
|
232
|
+
if (pending.phase !== 'activated') throw new Error('board successor was never activated');
|
|
233
|
+
if (pending.candidate.ended) {
|
|
234
|
+
await terminate(pending.candidate);
|
|
235
|
+
const replacement = start(pending.candidate.selected);
|
|
236
|
+
try { await replacement.wait('prepared'); await activate(replacement); pending.candidate = replacement; }
|
|
237
|
+
catch (error) { await terminate(replacement); throw error; }
|
|
238
|
+
}
|
|
239
|
+
if (routing !== pending.candidate || active.ended) throw new Error('board successor is not serving; handover cannot finish');
|
|
240
|
+
for (const retained of generations) if (retained !== active && retained !== previous) await terminate(retained);
|
|
241
|
+
pending = null; record(active);
|
|
242
|
+
} else if (!['prepare', 'activate'].includes(action)) throw new Error('unknown board handover action');
|
|
243
|
+
return status();
|
|
244
|
+
};
|
|
245
|
+
const answer = async line => {
|
|
246
|
+
let request;
|
|
247
|
+
try { request = JSON.parse(line); } catch { /* generation answers malformed input */ }
|
|
248
|
+
if (request?.v === 1 && Array.isArray(request.argv) && request.argv[0] === 'handover') {
|
|
249
|
+
const parsed = parse(request.argv);
|
|
250
|
+
if (parsed.action === 'status') return { code: 0, stdout: JSON.stringify(status()) + '\n', stderr: '' };
|
|
251
|
+
if (transition) return failed(new Error('a board handover is already in progress'));
|
|
252
|
+
transition = control(parsed);
|
|
253
|
+
try { return { code: 0, stdout: JSON.stringify(await transition) + '\n', stderr: '' }; }
|
|
254
|
+
catch (error) { return failed(error); }
|
|
255
|
+
finally { transition = null; changed(); }
|
|
256
|
+
}
|
|
257
|
+
while (!routing && !stopping) {
|
|
258
|
+
if (recoveryFailure && !transition) return failed(recoveryFailure);
|
|
259
|
+
if (active?.ended && (!previous || previous.ended) && !transition) return failed(new Error('board generation ended; no rollback generation is available'));
|
|
260
|
+
await new Promise(done => waiting.add(done));
|
|
261
|
+
}
|
|
262
|
+
return stopping ? failed(new Error('the board owner is stopping')) : routing.ask(line);
|
|
263
|
+
};
|
|
264
|
+
let closeDoor;
|
|
265
|
+
const stop = () => {
|
|
266
|
+
if (stopping) return;
|
|
267
|
+
stopping = true; changed();
|
|
268
|
+
for (const generation of generations) if (!generation.ended) generation.process.kill('SIGTERM');
|
|
269
|
+
};
|
|
270
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);
|
|
271
|
+
try {
|
|
272
|
+
// Only this lock's holder publishes/removes the public socket, for its entire lifetime.
|
|
273
|
+
rmSync(boardOwnerSocket(root), { force: true });
|
|
274
|
+
closeDoor = await openOwnerDoor(root, { machine: localMachine(), answer, log, readListings: false });
|
|
275
|
+
const first = start({ entry, native: process.env.SUPERCODE_BIN, candidate: process.env.SUPERCODE_SOURCE_CANDIDATE || null, release: process.env.SUPERCODE_SOURCE_CANDIDATE ? null : retainedReceipt(entry) });
|
|
276
|
+
await first.wait('prepared');
|
|
277
|
+
await activate(first);
|
|
278
|
+
if (typeof process.send === 'function' && Number(process.env.SUPERCODE_BOARD_START_OWNER) === process.ppid)
|
|
279
|
+
process.send({ type: 'board-owner-ready', owner: process.pid });
|
|
280
|
+
if (args.pidfile) writeFileSync(args.pidfile, `${process.pid}\n`);
|
|
281
|
+
while (!stopping) await new Promise(done => waiting.add(done));
|
|
282
|
+
} finally {
|
|
283
|
+
stop();
|
|
284
|
+
await Promise.all([...generations].map(terminate));
|
|
285
|
+
closeDoor?.();
|
|
286
|
+
rmSync(boardOwnerSocket(root), { force: true });
|
|
287
|
+
try { if (JSON.parse(readFileSync(recordPath, 'utf8')).owner === process.pid) rmSync(recordPath); } catch { /* absent */ }
|
|
288
|
+
lock.release();
|
|
289
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.off(signal, stop);
|
|
290
|
+
if (args.pidfile) rmSync(args.pidfile, { force: true });
|
|
291
|
+
}
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// A board generation's cooperative writer boundary. The stable owner holds board.lock and its public socket;
|
|
2
|
+
// only its active child may enter work. Parking drains admitted work before acknowledging loss of authority.
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
|
|
5
|
+
export const inBoardGeneration = typeof process.send === 'function'
|
|
6
|
+
&& Number(process.env.SUPERCODE_BOARD_GENERATION_OWNER) === process.ppid;
|
|
7
|
+
const nested = new AsyncLocalStorage();
|
|
8
|
+
let admitting = !inBoardGeneration, writing = !inBoardGeneration, running = 0, ended = false;
|
|
9
|
+
let waiters = [], drained = [];
|
|
10
|
+
|
|
11
|
+
export async function generationWork(work) {
|
|
12
|
+
if (!inBoardGeneration) return work();
|
|
13
|
+
while (!admitting && !nested.getStore()?.active) {
|
|
14
|
+
if (ended) throw new Error('the board generation stopped before this work began');
|
|
15
|
+
await new Promise((done, reject) => waiters.push({ done, reject }));
|
|
16
|
+
}
|
|
17
|
+
running++;
|
|
18
|
+
const scope = { active: true };
|
|
19
|
+
try { return await nested.run(scope, work); }
|
|
20
|
+
finally { scope.active = false; if (--running === 0) { for (const done of drained.splice(0)) done(); } }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function assertGenerationWriter() {
|
|
24
|
+
if (inBoardGeneration && !writing) throw new Error('this board generation is parked; no write was made');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function admitGeneration() {
|
|
28
|
+
writing = true; admitting = true;
|
|
29
|
+
for (const { done } of waiters.splice(0)) done();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function grantGenerationWriter() { writing = true; }
|
|
33
|
+
export function stopGeneration() {
|
|
34
|
+
ended = true; admitting = false;
|
|
35
|
+
for (const { reject } of waiters.splice(0)) reject(new Error('the board generation is stopping'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function parkGeneration(settle) {
|
|
39
|
+
admitting = false;
|
|
40
|
+
if (running) await new Promise(done => drained.push(done));
|
|
41
|
+
// Document settlement can enter ownCall itself. It is admitted as the final writer scope, never queued behind
|
|
42
|
+
// the admission boundary it is settling. Detached children of that scope must drain before authority is revoked.
|
|
43
|
+
const scope = { active: true };
|
|
44
|
+
try { await nested.run(scope, settle); } finally { scope.active = false; }
|
|
45
|
+
if (running) await new Promise(done => drained.push(done));
|
|
46
|
+
writing = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const tellOwner = frame => { if (inBoardGeneration && process.connected) process.send(frame); };
|
package/board/listing.mjs
CHANGED
|
@@ -54,8 +54,19 @@ export const TASK_SUMMARY_INDEX_SQL = `CREATE INDEX IF NOT EXISTS ${TASK_SUMMARY
|
|
|
54
54
|
const RUN_METADATA = `CASE WHEN json_valid(r.metadata) THEN json_object('supercode', json_object(
|
|
55
55
|
'address',json_extract(r.metadata,'$.supercode.address'),'session_id',json_extract(r.metadata,'$.supercode.session_id'),
|
|
56
56
|
'close_confirmed_at',json_extract(r.metadata,'$.supercode.close_confirmed_at'),'released',json_extract(r.metadata,'$.supercode.released'),
|
|
57
|
-
'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation')
|
|
58
|
-
))
|
|
57
|
+
'presence_observation',json_extract(r.metadata,'$.supercode.presence_observation'),'idle_observation',json_extract(r.metadata,'$.supercode.idle_observation'),
|
|
58
|
+
'machine',json_extract(r.metadata,'$.supercode.machine'),'offline_since',json_extract(r.metadata,'$.supercode.offline_since'),
|
|
59
|
+
'idle_since',json_extract(r.metadata,'$.supercode.idle_since'),'unknown_since',json_extract(r.metadata,'$.supercode.unknown_since'),
|
|
60
|
+
'question',json_extract(r.metadata,'$.supercode.question.question')
|
|
61
|
+
), 'last_events', json_object('heartbeat', json_extract(r.metadata,'$.last_events.heartbeat'))) END`;
|
|
62
|
+
// What a working card's session reading needs beside its current run (session-work.mjs executionOf, readingContext reads
|
|
63
|
+
// the same): the current run's latest heartbeat event (a declared wait, --until) and, with no current run, its latest
|
|
64
|
+
// run. Read only for the working lanes.
|
|
65
|
+
const WORKING_LANE = "COALESCE(c.stage,t.status) IN ('running','review')";
|
|
66
|
+
const READING_CONTEXT = `CASE WHEN ${WORKING_LANE} THEN (SELECT json_object('payload',CASE WHEN json_valid(e.payload) THEN json(e.payload) END,'at',e.created_at,'run_id',e.run_id)
|
|
67
|
+
FROM task_events e WHERE e.task_id = t.id AND e.kind = 'heartbeat' AND e.run_id = t.current_run_id ORDER BY e.id DESC LIMIT 1) END AS heartbeat_event,
|
|
68
|
+
CASE WHEN ${WORKING_LANE} AND t.current_run_id IS NULL THEN (SELECT json_object('status',l.status,'outcome',l.outcome,'ended_at',l.ended_at,'error',substr(l.error,1,300))
|
|
69
|
+
FROM task_runs l WHERE l.task_id = t.id ORDER BY l.id DESC LIMIT 1) END AS last_run`;
|
|
59
70
|
export const LIST_CANDIDATES_SQL = `SELECT MAX(id) AS last, json_group_array(id) AS ids FROM (
|
|
60
71
|
SELECT id FROM tasks WHERE id > ? AND id <= ? ORDER BY id LIMIT ${LIST_BATCH})`;
|
|
61
72
|
|
|
@@ -71,7 +82,8 @@ export const LAST_BLOCK_SQL = `SELECT CASE WHEN kind = 'blocked' THEN payload EN
|
|
|
71
82
|
export function listPageSql(detailed, tagCount = 0, options = {}) {
|
|
72
83
|
return `SELECT t.rowid AS list_order,${(detailed ? JSON_FIELDS : HUMAN_FIELDS).split(',').map(name => `t.${name}`).join(',')},
|
|
73
84
|
n.number,c.machine,c.stage,c.paused,c.blocked_by,c.detached${detailed ? ',c.subtask_of' : ''},
|
|
74
|
-
r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata
|
|
85
|
+
r.id AS run_id,r.status AS run_status,r.started_at AS run_started_at,r.ended_at AS run_ended_at,${RUN_METADATA} AS run_metadata,
|
|
86
|
+
${READING_CONTEXT}
|
|
75
87
|
${detailed ? `,(SELECT json_group_array(parent_id) FROM (SELECT parent_id FROM task_links WHERE child_id = t.id ORDER BY parent_id)) AS parents_json,
|
|
76
88
|
(${LAST_BLOCK_SQL}) AS block_payload` : ''}
|
|
77
89
|
FROM tasks t ${detailed ? '' : `INDEXED BY ${TASK_SUMMARY_INDEX}`} LEFT JOIN supercode_cards c ON c.task_id = t.id LEFT JOIN supercode_card_numbers n ON n.task_id = t.id
|
|
@@ -108,10 +120,10 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
|
|
|
108
120
|
status, status, status, status, assignee, assignee, machine, machine, includeSubtasks ? 1 : 0, includeArchived ? 1 : 0,
|
|
109
121
|
tenant, tenant, session, session, workflowTemplateId, workflowTemplateId, stepKey, stepKey, ...tags.flatMap(tag => [tag, tag, tag])));
|
|
110
122
|
measured('row projection', () => {
|
|
111
|
-
for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, parents_json, block_payload, ...task } of rows) {
|
|
123
|
+
for (const { run_id, run_status, run_started_at, run_ended_at, run_metadata, heartbeat_event, last_run, parents_json, block_payload, ...task } of rows) {
|
|
112
124
|
const metadata = run_id ? parse(run_metadata, {}) : null;
|
|
113
125
|
const run = run_id ? { id: run_id, status: run_status, started_at: run_started_at, ended_at: run_ended_at, metadata } : null;
|
|
114
|
-
task.execution = executionOf(run);
|
|
126
|
+
task.execution = executionOf(run, { heartbeat: parse(heartbeat_event, null), last: parse(last_run, null) });
|
|
115
127
|
if (detailed) {
|
|
116
128
|
task.skills = parse(task.skills, []);
|
|
117
129
|
task.workflow_status = task.stage ?? task.status;
|
|
@@ -12,18 +12,24 @@ import { ownHome } from './owner-files.mjs';
|
|
|
12
12
|
import { writeOwnerAnswer } from './reply.mjs';
|
|
13
13
|
import { holdWorkflow } from './workflow.mjs';
|
|
14
14
|
|
|
15
|
-
const { root, machine } = workerData;
|
|
15
|
+
const { root, machine, readListings = true } = workerData;
|
|
16
16
|
const waiting = new Map(); // request id → its answer's resolve
|
|
17
|
-
|
|
17
|
+
const MAX_REQUEST = 8 * 1024 * 1024, MAX_PENDING = 64 * 1024 * 1024;
|
|
18
|
+
let next = 0, pendingBytes = 0, inputBytes = 0, connections = 0;
|
|
19
|
+
const refused = message => ({ code: 1, stdout: '', stderr: `supercode workflow: ${message}; no operation was submitted\n` });
|
|
18
20
|
parentPort.on('message', (frame) => {
|
|
19
21
|
if (frame.type === 'health') holdOwnerHealth(frame.boards, frame.round);
|
|
20
22
|
else if (frame.type === 'workflow') holdWorkflow(frame.root, frame.entry);
|
|
21
|
-
else if (frame.type === 'answer') { waiting.get(frame.id)
|
|
23
|
+
else if (frame.type === 'answer') { const held = waiting.get(frame.id); if (held) { pendingBytes -= held.bytes; waiting.delete(frame.id); held.resolve(frame.answer); } }
|
|
22
24
|
});
|
|
23
25
|
await knowLocalMachine(machine);
|
|
24
26
|
ownHome(root);
|
|
25
27
|
|
|
26
|
-
const byOwner =
|
|
28
|
+
const byOwner = line => {
|
|
29
|
+
const bytes = Buffer.byteLength(line);
|
|
30
|
+
if (waiting.size >= 256 || pendingBytes + inputBytes + bytes > MAX_PENDING) return Promise.resolve(refused('the owner request queue reached its bound'));
|
|
31
|
+
return new Promise(resolve => { const id = ++next; pendingBytes += bytes; waiting.set(id, { resolve, bytes }); parentPort.postMessage({ type: 'request', id, line }); });
|
|
32
|
+
};
|
|
27
33
|
const isListing = (line) => {
|
|
28
34
|
try {
|
|
29
35
|
const request = JSON.parse(line);
|
|
@@ -31,23 +37,32 @@ const isListing = (line) => {
|
|
|
31
37
|
} catch { return false; } // the owner answers what it cannot read
|
|
32
38
|
};
|
|
33
39
|
const answer = async (line) => {
|
|
34
|
-
if (!isListing(line)) return byOwner(line);
|
|
40
|
+
if (!readListings || !isListing(line)) return byOwner(line);
|
|
35
41
|
const read = await answerOne(root, line, { readOnly: true });
|
|
36
42
|
return read.byOwner ? byOwner(line) : read;
|
|
37
43
|
};
|
|
38
44
|
|
|
39
45
|
// allowHalfOpen: a caller that ends its side after its request still gets the answer, written when it is ready
|
|
40
46
|
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
|
41
|
-
|
|
47
|
+
if (connections >= 256) { socket.destroy(); return; }
|
|
48
|
+
connections++;
|
|
49
|
+
let input = '', heldBytes = 0;
|
|
50
|
+
const release = () => { inputBytes -= heldBytes; heldBytes = 0; };
|
|
51
|
+
socket.once('close', () => { release(); connections--; });
|
|
42
52
|
socket.setEncoding('utf8');
|
|
43
53
|
socket.on('error', () => { /* the caller went away */ });
|
|
44
54
|
socket.on('data', (chunk) => {
|
|
45
55
|
if (input === null) return;
|
|
56
|
+
const bytes = Buffer.byteLength(chunk);
|
|
57
|
+
if (heldBytes + bytes > MAX_REQUEST || pendingBytes + inputBytes + bytes > MAX_PENDING) {
|
|
58
|
+
release(); input = null; void writeOwnerAnswer(socket, refused('the request exceeded its byte bound')).catch(() => socket.destroy()); return;
|
|
59
|
+
}
|
|
60
|
+
heldBytes += bytes; inputBytes += bytes;
|
|
46
61
|
input += chunk;
|
|
47
62
|
const end = input.indexOf('\n');
|
|
48
63
|
if (end < 0) return;
|
|
49
64
|
const line = input.slice(0, end);
|
|
50
|
-
input = null;
|
|
65
|
+
input = null; release();
|
|
51
66
|
// one answer per call, whatever the request did: a request that fails is a failed answer, never the door's end
|
|
52
67
|
answer(line)
|
|
53
68
|
.catch((error) => ({ code: 1, stdout: '', stderr: `supercode workflow: ${error?.message ?? error}\n` }))
|
package/board/owner-door.mjs
CHANGED
|
@@ -16,8 +16,8 @@ const failed = (error) => ({ code: 1, stdout: '', stderr: `supercode workflow: $
|
|
|
16
16
|
* it. A door that ends while the owner runs leaves the home with no door: the owner says so and exits, for its keeper to
|
|
17
17
|
* start it again. Answers the door's close.
|
|
18
18
|
*/
|
|
19
|
-
export async function openOwnerDoor(root, { machine, answer, log }) {
|
|
20
|
-
const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine } });
|
|
19
|
+
export async function openOwnerDoor(root, { machine, answer, log, readListings = true }) {
|
|
20
|
+
const worker = new Worker(new URL('./owner-door-worker.mjs', import.meta.url), { name: 'supercode board door', workerData: { root, machine, readListings } });
|
|
21
21
|
let open = false, closing = false;
|
|
22
22
|
const send = (frame) => { try { worker.postMessage(frame); } catch (error) { log(`board door: could not hand the door ${frame.type}: ${error.message}`); } };
|
|
23
23
|
const stops = [
|