@volter/supercode-orchestrator 0.5.204 → 0.5.206
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 +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/apply/doors.mjs +25 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +225 -87
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +297 -75
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +78 -24
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +5 -0
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +55 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +91 -28
- package/board/ztrack.mjs +87 -21
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/board/dispatch.mjs
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
// acts on and does nothing when another round already acted (see `tick`). Every side effect follows a committed claim, so
|
|
19
19
|
// a crash between the two steps is answered by the next round.
|
|
20
20
|
import { machineCall } from './doors.mjs';
|
|
21
|
+
import { detachedCard, detachedSession, personCustody, sessionOperation, takeSessionCustody } from './custody.mjs';
|
|
21
22
|
import { processSession } from './caller-session.mjs';
|
|
22
23
|
import { callerOf, current, isCallersCall, ownCall } from './call.mjs';
|
|
23
24
|
import { noteStep } from '@volter/supercode-harness-sdk/slow-log';
|
|
24
25
|
import { monitorsRuns, recoversRuns, retainRecovery, failedObservation, surfaceIncident, auditEndedRuns, originOf } from './incidents.mjs';
|
|
25
26
|
import { readMachineFleet } from './machine-fleet.mjs';
|
|
27
|
+
import { homeFleetMachines } from './fleet-targets.mjs';
|
|
26
28
|
import { delegatedWork, idleInterval } from './session-work.mjs';
|
|
27
29
|
import { localSessionDelegations } from '../agents.mjs';
|
|
28
30
|
import { creatorOf, sessionProgress } from './workflow.mjs';
|
|
@@ -35,6 +37,8 @@ import { agentScope, localCardAgent, localReaderContext, localAgentSessions, run
|
|
|
35
37
|
import { finishCardAgentAssignment, recordCardAgentAssignment } from './agent-declarations.mjs';
|
|
36
38
|
import { materializeAgentHome } from '../agent-home.mjs';
|
|
37
39
|
import { memorySnapshot } from '../hermes-cli.mjs';
|
|
40
|
+
import { hermesModel } from '../worker-route.mjs';
|
|
41
|
+
import { MODEL_ARGUMENTS } from '../activation.mjs';
|
|
38
42
|
import { createRequire } from 'node:module';
|
|
39
43
|
import { spawn as spawnChild, execFile } from 'node:child_process';
|
|
40
44
|
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
@@ -48,10 +52,11 @@ import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace }
|
|
|
48
52
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
49
53
|
import { evaluate, holds, render } from './expr.mjs';
|
|
50
54
|
import { identifyNotice } from './notices.mjs';
|
|
55
|
+
import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
|
|
51
56
|
import { pendingRequestMail } from './request-mail.mjs';
|
|
52
57
|
import {
|
|
53
58
|
cardContext, claimTtl, currentRun, DEFAULT_FAILURE_LIMIT, extendClaim, getTask, lastSession,
|
|
54
|
-
recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, subscriptions, reconcileDependencySubscriptions,
|
|
59
|
+
recomputeReady, runEffects, queueEffects, setRunSession, subscribeIn, replaceWorkerSubscriptions, subscriptions, reconcileDependencySubscriptions,
|
|
55
60
|
} from './engine.mjs';
|
|
56
61
|
|
|
57
62
|
const WORKER = fileURLToPath(new URL('../bin/hermes-worker.mjs', import.meta.url));
|
|
@@ -66,8 +71,10 @@ export function normalMachine(name) {
|
|
|
66
71
|
|
|
67
72
|
/** This machine's name as supercode's addresses spell it: its Teams enrollment, else its host name. */
|
|
68
73
|
let localMachineName;
|
|
69
|
-
/** Learn it once per process, from the machine's own daemon (in-process: doors.mjs); a host with no daemon is its OS name.
|
|
70
|
-
|
|
74
|
+
/** Learn it once per process, from the machine's own daemon (in-process: doors.mjs); a host with no daemon is its OS name.
|
|
75
|
+
* `known`: the name this process's owner already learned (the owner's read door, owner-door.mjs). */
|
|
76
|
+
export async function knowLocalMachine(known = null) {
|
|
77
|
+
if (known && !localMachineName) localMachineName = normalMachine(known);
|
|
71
78
|
if (localMachineName) return localMachineName;
|
|
72
79
|
try { const machine = await machineCall(null, 'harness.v1.machine.describe', {}, { timeoutMs: 15_000 }); localMachineName = normalMachine(machine.name ?? machine.hostname); }
|
|
73
80
|
catch { localMachineName = normalMachine(hostname()); }
|
|
@@ -144,17 +151,7 @@ export function callerSession() {
|
|
|
144
151
|
/** The fleet as the home's machines answer for themselves (machine-fleet.mjs): this machine, and every machine an open
|
|
145
152
|
* card of the home names or one of its notice subscribers is on. */
|
|
146
153
|
export async function observeFleet(root = fleetRoot) {
|
|
147
|
-
const machines =
|
|
148
|
-
for (const slug of root ? listBoards(root) : []) {
|
|
149
|
-
try {
|
|
150
|
-
const b = openBoard(root, slug);
|
|
151
|
-
try {
|
|
152
|
-
const live = "t.status NOT IN ('done', 'cancelled', 'archived') AND NOT COALESCE(c.archived, 0)";
|
|
153
|
-
for (const { machine } of b.db.prepare(`SELECT DISTINCT c.machine FROM supercode_cards c JOIN tasks t ON t.id = c.task_id WHERE c.machine IS NOT NULL AND c.machine != '' AND ${live}`).all()) machines.add(normalMachine(machine));
|
|
154
|
-
for (const { chat_id: address } of b.db.prepare(`SELECT DISTINCT s.chat_id FROM kanban_notify_subs s JOIN tasks t ON t.id = s.task_id JOIN supercode_cards c ON c.task_id = t.id WHERE s.platform = 'supercode' AND s.chat_id LIKE 'sc:%' AND ${live}`).all()) machines.add(normalMachine(address.split(':')[1]));
|
|
155
|
-
} finally { b.close(); }
|
|
156
|
-
} catch { /* its own tick reports it */ }
|
|
157
|
-
}
|
|
154
|
+
const machines = await homeFleetMachines(root, normalMachine);
|
|
158
155
|
const fleet = await readMachineFleet(machines, localMachine());
|
|
159
156
|
presenceReading = { known: fleet.presenceKnown === true, online: new Set([localMachine(), ...fleet.online]), unknown: fleet.unknown ?? new Set() };
|
|
160
157
|
notePresence(presenceReading);
|
|
@@ -389,7 +386,8 @@ export function lanesOf(profiles = {}) {
|
|
|
389
386
|
for (const [name, p] of Object.entries(profiles)) {
|
|
390
387
|
const w = p?.worker;
|
|
391
388
|
if (!w?.harness) continue;
|
|
392
|
-
|
|
389
|
+
const model = w.model ?? (MODEL_ARGUMENTS[w.harness] ? hermesModel(p)?.model ?? null : null);
|
|
390
|
+
out[name] = { name, dir: p.dir, harness: w.harness, model, reasoning_effort: w.reasoning_effort ?? null, surface: w.surface ?? 'headless', capacity: w.capacity ?? null };
|
|
393
391
|
}
|
|
394
392
|
return out;
|
|
395
393
|
}
|
|
@@ -444,16 +442,43 @@ async function recordBounces(board, fleet, entry, report) {
|
|
|
444
442
|
try {
|
|
445
443
|
const answer = await runtimeDoor(null, 'harness.v1.mail.expired', {}, { timeout: 15_000 });
|
|
446
444
|
const expired = answer.code === 0 ? lastJson(answer.stdout)?.rows ?? [] : [];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
445
|
+
const eligible = expired.filter((row) => row?.bounced === true && row.id && at - Number(row.expired_at_ms) < 24 * 3600_000);
|
|
446
|
+
if (eligible.length) {
|
|
447
|
+
const receipts = await noticeReceiptsInTurns(board, eligible.map(row => row.id));
|
|
448
|
+
const pending = eligible.filter(row => {
|
|
449
|
+
const filed = receipts.filed.get(row.id);
|
|
450
|
+
return filed && !receipts.bounced.has(noticeKey(filed.task_id, row.id));
|
|
451
|
+
});
|
|
452
|
+
let revision = receipts.revision;
|
|
453
|
+
// Eight expiries per writer turn; the historical search and duplicate lookup have already yielded.
|
|
454
|
+
for (let offset = 0; offset < pending.length; offset += 8) {
|
|
455
|
+
requireNoticeBasis(board, revision);
|
|
456
|
+
const recorded = tx(board, db => {
|
|
457
|
+
const basis = noticeWriterBasis(board, revision, receipts.ceiling);
|
|
458
|
+
const added = new Set(), staged = [];
|
|
459
|
+
for (const expiry of pending.slice(offset, offset + 8)) {
|
|
460
|
+
const found = receipts.filed.get(expiry.id), key = noticeKey(found.task_id, expiry.id);
|
|
461
|
+
if (receipts.bounced.has(key) || added.has(key)) continue;
|
|
462
|
+
// Revalidate the selected immutable receipt by primary key; no historical payload predicate in the writer.
|
|
463
|
+
const filed = db.prepare("SELECT task_id, payload FROM task_events WHERE id = ? AND kind IN ('notice_filed', 'session_notice_filed', 'actor_notice_filed') AND json_extract(payload, '$.message_id') = ?").get(found.id, expiry.id);
|
|
464
|
+
if (!filed) continue;
|
|
465
|
+
const run = currentRun(db, filed.task_id), session = run?.metadata?.supercode;
|
|
466
|
+
const uncounted = Boolean(run && !run.ended_at && json(filed.payload, {})?.verb === 'session_idle' && addressOf(session) === expiry.to && Number(session.goal_turns) > 0);
|
|
467
|
+
if (uncounted) setRunSession(db, run.id, { goal_turns: Number(session.goal_turns) - 1 });
|
|
468
|
+
event(db, filed.task_id, 'notice_bounced', { message_id: expiry.id, to: expiry.to, reason: expiry.reason, expired_at_ms: expiry.expired_at_ms, idle_turn_uncounted: uncounted }, run?.id ?? null);
|
|
469
|
+
tell(db, filed.task_id, `${filed.task_id}: the board's notice ${expiry.id} to ${expiry.to} bounced: ${expiry.reason}. It stays unread in that mailbox and is not sent again.`);
|
|
470
|
+
added.add(key);
|
|
471
|
+
staged.push([filed.task_id, expiry.to, expiry.id]);
|
|
472
|
+
}
|
|
473
|
+
return { rows: staged, revision: basis, ceiling: db.prepare('SELECT MAX(id) AS id FROM task_events').get().id };
|
|
474
|
+
});
|
|
475
|
+
for (const row of recorded.rows) receipts.bounced.add(noticeKey(row[0], row[2]));
|
|
476
|
+
(report.bounced ??= []).push(...recorded.rows);
|
|
477
|
+
receipts.ceiling = recorded.ceiling;
|
|
478
|
+
revision = requireNoticeBasis(board, recorded.revision, { ownWrites: true });
|
|
479
|
+
if (offset + 8 < pending.length) await yieldToOwner();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
457
482
|
} catch (error) { report.undelivered?.push(['mail', 'bounces', String(error.message).slice(-200)]); }
|
|
458
483
|
// the first input of each session launched in the last ten minutes, until its machine's launch record settles it
|
|
459
484
|
const running = view(board, (db) => db.prepare('SELECT t.id, r.id run_id, r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id WHERE r.ended_at IS NULL').all());
|
|
@@ -481,8 +506,25 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
481
506
|
// Published native observations are the only liveness input. Missing data remains unknown. A launch the
|
|
482
507
|
// observations show stopped is asked, through its machine's door, how it ended.
|
|
483
508
|
const exits = new Map();
|
|
509
|
+
const firstResponses = new Map();
|
|
484
510
|
// every running session's liveness is read before anything is decided of it (unknown stays unknown)
|
|
485
511
|
for (const { id } of rows) await readBefore(fleet, currentRun(board.db, id)?.metadata?.supercode);
|
|
512
|
+
// A living terminal can have refused its first turn. Read the native
|
|
513
|
+
// assistant outcome through the session door, before any board write.
|
|
514
|
+
for (const { id } of rows) {
|
|
515
|
+
const run = currentRun(board.db, id), session = run?.metadata?.supercode;
|
|
516
|
+
if (session?.harness !== 'claude-code' || session.mode !== 'fresh' || !session.launch_key || session.first_response || !session.session_id) continue;
|
|
517
|
+
let locator;
|
|
518
|
+
try { locator = nativeReading(nativeSessions(session.machine, session.harness, session.session_id), session).row?.locator; } catch { continue; }
|
|
519
|
+
if (!locator) continue;
|
|
520
|
+
const answer = await doorOn(session.machine, 'harness.v1.sessions.load', { locator, options: { include_subagents: false, message_limit: 0, inline_media: 'metadata' } }, { timeout: NATIVE_DISCOVERY_MS });
|
|
521
|
+
const loaded = answer.code === 0 ? lastJson(answer.stdout) : null;
|
|
522
|
+
const response = loaded?.session?.first_response;
|
|
523
|
+
if (loaded?.session?.session_id !== session.session_id || !['response', 'error'].includes(response?.outcome)
|
|
524
|
+
|| !Number.isFinite(Date.parse(response.at)) || !Number.isFinite(run.started_at)
|
|
525
|
+
|| Date.parse(response.at) < run.started_at * 1000) continue;
|
|
526
|
+
firstResponses.set(id, { run: run.id, session: session.session_id, response });
|
|
527
|
+
}
|
|
486
528
|
for (const { id } of rows) {
|
|
487
529
|
const session = currentRun(board.db, id)?.metadata?.supercode;
|
|
488
530
|
if (!session?.launch_key || !fleet.ok || sessionAlive(fleet, session) !== false) continue;
|
|
@@ -548,6 +590,16 @@ async function supervise(board, fleet, settings, report, entry, effects) {
|
|
|
548
590
|
}
|
|
549
591
|
};
|
|
550
592
|
const limits = limitsOf(db, board, entry, task, session);
|
|
593
|
+
const first = firstResponses.get(id);
|
|
594
|
+
if (first?.run === runRow?.id && first.session === session?.session_id && !session.first_response) {
|
|
595
|
+
session = { ...session, first_response: first.response };
|
|
596
|
+
setRunSession(db, runRow.id, session);
|
|
597
|
+
if (first.response.outcome === 'error') {
|
|
598
|
+
observe('exited', { error: `native session ${session.session_id} refused its first response (${first.response.error_code ?? 'native API error'})`,
|
|
599
|
+
session_id: session.session_id, pane: session.pane, first_response: first.response }, 'crashed');
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
551
603
|
// A verified adoption binds the run to its session; a launch key still pending beside it is the reconciler's to
|
|
552
604
|
// retire at its machine, and no longer what the run waits on.
|
|
553
605
|
const adopted = session?.mode === 'adopt' && Boolean(session.address);
|
|
@@ -714,6 +766,11 @@ function limitsOf(db, board, entry, task, session) {
|
|
|
714
766
|
|
|
715
767
|
async function stopSession(board, session) {
|
|
716
768
|
if (!session) return;
|
|
769
|
+
if (view(board, db => detachedSession(db, session))) throw Object.assign(new Error('close deferred: session was detached to a person'), { sessionDetached: true });
|
|
770
|
+
return sessionOperation(board, { address: addressOf(session), kind: 'session close' }, () => stopOwnedSession(board, session));
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function stopOwnedSession(board, session) {
|
|
717
774
|
if (boardCopyOf(board)) throw copyRefusal(board, 'closes no session');
|
|
718
775
|
if (session.surface === 'headless' && pidAlive(session.pid)) {
|
|
719
776
|
// SIGTERM to the worker's process group, then SIGKILL if it is still there after a grace, as Hermes stops its own
|
|
@@ -764,11 +821,11 @@ async function homeHoldings(root, slugs, lanes, fleet, entry) {
|
|
|
764
821
|
// each run's session read before it is counted live or not (unknown stays unknown)
|
|
765
822
|
for (const r of b.db.prepare('SELECT r.metadata FROM tasks t JOIN task_runs r ON r.id = t.current_run_id').all()) await readBefore(fleet, json(r.metadata, {})?.supercode);
|
|
766
823
|
const storedOf = (id) => storedStatus(b.db, b, entry, id).stored;
|
|
767
|
-
out.push(...holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
|
|
824
|
+
out.push(...await holdings(b, { lanes, fleetOk: fleet.ok, alive: (s) => sessionAlive(fleet, s), liveRecord: live, modelOf: transcriptModel, storedOf, machineOf: normalMachine, home: localMachine(),
|
|
768
825
|
runLaneOf: row => runLane(b.db, b, entry, getTask(b.db, row.id), lanes) }));
|
|
769
826
|
}
|
|
770
827
|
finally { b.close(); }
|
|
771
|
-
} catch { /* its own tick reports
|
|
828
|
+
} catch (error) { if (error.capacityUnread) throw error; /* its own tick reports other board errors */ }
|
|
772
829
|
}
|
|
773
830
|
out.defaults = harnessDefaults(out);
|
|
774
831
|
return out;
|
|
@@ -832,7 +889,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
832
889
|
const tickState = { local_started: 0 };
|
|
833
890
|
const startable = new Set(Object.keys(workflow.statuses).filter((status) => transitionsFor(workflow, status, d.start).length));
|
|
834
891
|
const everyCard = board.db.prepare("SELECT id FROM tasks WHERE status != 'archived'").all().map((r) => getTask(board.db, r.id));
|
|
835
|
-
const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
|
|
892
|
+
const scopeFor = (task, extra = {}) => scopeOf(board.db, board, task, { params, lazy: true, extra: { host: hostOf(pressure), tick: tickState, ...extra } });
|
|
836
893
|
// who would run it: the role of the status its start moves it to, and that role's lane
|
|
837
894
|
const planOf = (task, scope) => {
|
|
838
895
|
const status = statusOf(board.db, task);
|
|
@@ -855,6 +912,7 @@ async function startCards(board, fleet, lanes, settings, report, entry, effects,
|
|
|
855
912
|
if (task.subtask_of || task.current_run_id) continue;
|
|
856
913
|
const state = scopeFor(task).card;
|
|
857
914
|
if (state.archived || ['done', 'cancelled'].includes(state.status)) continue;
|
|
915
|
+
if (state.detached) { waiting(task, `session belongs to ${state.detached.person}; verified adoption is required`); continue; }
|
|
858
916
|
if (state.dispatch_hold) { waiting(task, 'dispatch held: an explicit start or verified adoption must release it'); continue; }
|
|
859
917
|
if (workflow.run && state.paused) { waiting(task, `paused: ${state.paused.reason ?? 'manager pause'}`); continue; }
|
|
860
918
|
if (workflow.run && state.blocked_by.length) { waiting(task, `blocked by ${state.blocked_by.map(b => b.id).join(', ')}`); continue; }
|
|
@@ -989,7 +1047,7 @@ function launchFailed(board, entry, id, error, payload) {
|
|
|
989
1047
|
* longer the card's current one is its supersession, whatever failed, so no caller reports it against the successor. */
|
|
990
1048
|
export async function launchRun(board, entry, id, lane, fleet, settings) {
|
|
991
1049
|
const runId = getTaskNow(board, id).current_run_id;
|
|
992
|
-
try { return await launchAttempt(board, entry, id, lane, fleet, settings, runId); }
|
|
1050
|
+
try { return await sessionOperation(board, { taskId: id, kind: 'session launch' }, () => launchAttempt(board, entry, id, lane, fleet, settings, runId)); }
|
|
993
1051
|
catch (error) {
|
|
994
1052
|
error.launchRun = runId;
|
|
995
1053
|
if (!error.launchSuperseded && !view(board, (db) => runCurrent(db, id, runId))) error.launchSuperseded = true;
|
|
@@ -1021,7 +1079,19 @@ async function launchAttempt(board, entry, id, lane, fleet, settings, runId) {
|
|
|
1021
1079
|
// caps here too, excluding this attempt's own reservation. Other cap kinds keep their existing admission rules.
|
|
1022
1080
|
const sessionCaps = entry.workflow.dispatch.capacity?.filter(cap => cap.counts === 'sessions' && cap.where?.dispatch === true) ?? [];
|
|
1023
1081
|
if (sessionCaps.length) {
|
|
1024
|
-
|
|
1082
|
+
let held;
|
|
1083
|
+
try { held = await homeHoldings(board.root, listBoards(board.root), lanesOf(entry.profiles), fleet, entry); }
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
if (!error.capacityUnread) throw error;
|
|
1086
|
+
// No launch door has been issued. Keep this reservation retryable, including a deferred run whose
|
|
1087
|
+
// reconciliation claim just cleared launch_deferred. Top-level capacity reads still propagate unread.
|
|
1088
|
+
runTx(board, id, runId, db => {
|
|
1089
|
+
const previous = currentRun(db, id)?.metadata?.supercode;
|
|
1090
|
+
setRunSession(db, runId, { status, role: spec.role, slot, launched_at: now(), launch_deferred: true, launch_deferred_error: error.message });
|
|
1091
|
+
if (previous?.launch_deferred_error !== error.message) event(db, id, 'launch_deferred', { reason: error.message }, runId);
|
|
1092
|
+
});
|
|
1093
|
+
throw Object.assign(error, { launchDeferred: true, launchPending: true });
|
|
1094
|
+
}
|
|
1025
1095
|
const others = held.filter(x => !(x.board === board.slug && x.run === runId));
|
|
1026
1096
|
const refused = capacity(sessionCaps, others, evaluate).admit({ card: id, board: board.slug, machine: executionHost(lane, task.machine, localMachine(), normalMachine),
|
|
1027
1097
|
assignee: lane.name ?? task.assignee, status, stored: scope.card.stored ?? status, model: task.model_override ?? lane.model ?? null, harness: lane.harness,
|
|
@@ -1121,6 +1191,7 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
1121
1191
|
const fleet = await observeFleet();
|
|
1122
1192
|
const before = getTaskNow(board, id), checked = await checkWorkspace(board, before);
|
|
1123
1193
|
const moved = tx(board, (db) => {
|
|
1194
|
+
if (detachedCard(db, id)) throw new Error(`${id} is detached to a person; request and verify adoption before starting it`);
|
|
1124
1195
|
assertWorkspaceOwner(db, before, checked);
|
|
1125
1196
|
if (checked) db.prepare('UPDATE supercode_cards SET workspace_root=? WHERE task_id=?').run(checked.mainRoot, id);
|
|
1126
1197
|
if (!roles.some(role => ['manager', 'creator'].includes(role))) throw new Error('Only the manager or creator starts a held card; request its decision.');
|
|
@@ -1163,6 +1234,27 @@ export const unboundLaunch = (session, launchWindow, at = now()) => Boolean(sess
|
|
|
1163
1234
|
&& Number(session.launched_at) > 0 && at - Number(session.launched_at) >= launchWindow);
|
|
1164
1235
|
/** The fleet turns of a session read in a turn (`needs_input`: a turn stopped on a permission or a question). */
|
|
1165
1236
|
const TURN_IN_PROGRESS = new Set(['working', 'needs_input']);
|
|
1237
|
+
/**
|
|
1238
|
+
* A harness's own blocking prompt on the pane's screen, which no input from the board answers: the session is stopped,
|
|
1239
|
+
* not between turns. Claude Code's usage limit ("What do you want to do? 1. Stop and wait for limit to reset …") is
|
|
1240
|
+
* one; its reset time is said when the prompt names it. Null when the screen shows none.
|
|
1241
|
+
*/
|
|
1242
|
+
const BLOCKING_PROMPTS = [
|
|
1243
|
+
{ reason: 'provider-limit', harness: 'claude-code', test: /Stop and wait for limit to reset/i, resets: /continue automatically at ([^\n│]+?)\s*(?:│|$)/im },
|
|
1244
|
+
];
|
|
1245
|
+
async function blockingPrompt(session) {
|
|
1246
|
+
if (!session?.pane) return null;
|
|
1247
|
+
const machine = session.machine ?? localMachine();
|
|
1248
|
+
const r = await doorOn(machine, 'harness.v1.panes.capture', { pane: session.pane, lines: 60 }, { timeout: 10_000 });
|
|
1249
|
+
const text = r.code === 0 ? lastJson(r.stdout)?.text : null;
|
|
1250
|
+
if (typeof text !== 'string') return null; // unread (or a release without this door): the turn reading stands
|
|
1251
|
+
for (const prompt of BLOCKING_PROMPTS) {
|
|
1252
|
+
if (prompt.harness !== (session.harness ?? 'claude-code') || !prompt.test.test(text)) continue;
|
|
1253
|
+
const resets = prompt.resets.exec(text)?.[1]?.trim() ?? null;
|
|
1254
|
+
return { reason: prompt.reason, resets, observed_at: now() };
|
|
1255
|
+
}
|
|
1256
|
+
return null;
|
|
1257
|
+
}
|
|
1166
1258
|
/** The watch on a session whose turn is not published: it began (`since`) at the first such reading, at the reading
|
|
1167
1259
|
* that found its record written again (`mark`: its last write and when its harness says its state began, in ms), or
|
|
1168
1260
|
* at the first reading after more than `gap` seconds unread (`seen`: the last reading). */
|
|
@@ -1170,7 +1262,7 @@ function unreadWatch(held, mark, gap, at = now()) {
|
|
|
1170
1262
|
const kept = held && Number(held.mark) === mark && Number(held.since) <= at && at - Number(held.seen) <= gap;
|
|
1171
1263
|
return { since: kept ? Number(held.since) : at, mark, seen: at };
|
|
1172
1264
|
}
|
|
1173
|
-
async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0 } = {}) {
|
|
1265
|
+
async function turnAtClose(session, pane = null, { launchWindow = null, quiet = null, gap = 0, inputAbandonment = null } = {}) {
|
|
1174
1266
|
const machine = session.machine ?? localMachine();
|
|
1175
1267
|
const recordOf = (rows, id, harness) => rows.find((row) => row.locator?.session_id === id && row.locator?.harness === harness);
|
|
1176
1268
|
let activity, rows = null;
|
|
@@ -1208,12 +1300,21 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1208
1300
|
}
|
|
1209
1301
|
if (activity.presence && activity.presence !== 'running') return null;
|
|
1210
1302
|
if (activity.turn === 'idle') return null;
|
|
1303
|
+
if (inputAbandonment && activity.presence === 'running' && activity.turn === 'needs_input'
|
|
1304
|
+
&& activity.session_id === session.session_id && activity.harness === session.harness) {
|
|
1305
|
+
return { abandonInput: { ...inputAbandonment, observed_at: now(), session_id: activity.session_id,
|
|
1306
|
+
harness: activity.harness, pane: session.pane, turn: activity.turn } };
|
|
1307
|
+
}
|
|
1211
1308
|
const who = activity.session_id ? `sc:${normalMachine(machine)}:${activity.harness ?? session.harness}:${activity.session_id}` : addressOf(session);
|
|
1212
1309
|
const inTurn = (turn) => ({ why: `its session ${who} is in a turn (${turn}); it closes once the turn ends`, unread: null });
|
|
1213
1310
|
if (TURN_IN_PROGRESS.has(activity.turn)) return inTurn(activity.turn);
|
|
1214
1311
|
// No turn published: the session's own record says whether anything goes on in it. One whose record does not answer,
|
|
1215
1312
|
// or shows neither a write nor when its state began, has nothing to count from and waits.
|
|
1216
1313
|
const unknown = `its session ${who} publishes no turn (${activity.turn ?? 'unknown'})`;
|
|
1314
|
+
// A harness held at its own blocking prompt (a provider's usage limit) is stopped, whatever its record shows: closed,
|
|
1315
|
+
// with why, rather than waited on until the limit resets.
|
|
1316
|
+
const blocked = await blockingPrompt(session).catch(() => null);
|
|
1317
|
+
if (blocked) return { stopped: blocked };
|
|
1217
1318
|
const sessionId = activity.session_id ?? session.session_id, harness = activity.harness ?? session.harness ?? 'claude-code';
|
|
1218
1319
|
if (!sessionId || !quiet) return { why: unknown };
|
|
1219
1320
|
if (!rows) try { rows = (await discoverNative(machine, harness, sessionId)).sessions; }
|
|
@@ -1229,13 +1330,60 @@ async function turnAtClose(session, pane = null, { launchWindow = null, quiet =
|
|
|
1229
1330
|
/** Hold the close of an ended run's session for what `turnAtClose` read, with the watch that reading leaves on the run. */
|
|
1230
1331
|
async function holdForTurn(session, pane, options) {
|
|
1231
1332
|
const held = await turnAtClose(session, pane, options);
|
|
1333
|
+
if (held?.abandonInput) return held.abandonInput;
|
|
1334
|
+
if (held?.stopped) return { stopped: held.stopped };
|
|
1232
1335
|
if (held) throw Object.assign(new Error(`close deferred: ${held.why}`), 'unread' in held ? { closeUnread: held.unread } : {});
|
|
1233
1336
|
}
|
|
1234
1337
|
|
|
1338
|
+
/** Only a recorded manager/creator release of this ended worker permits abandoning input. */
|
|
1339
|
+
function reassignedInputClose(board, row, session) {
|
|
1340
|
+
if (!row.ended_at || !row.profile || !session.pane || !session.session_id || !session.harness
|
|
1341
|
+
|| (session.slot ?? 'worker') !== 'worker' || !session.launch_key || !['fresh', 'resume'].includes(session.mode)) return null;
|
|
1342
|
+
return view(board, (db) => {
|
|
1343
|
+
const release = db.prepare(`SELECT id, payload, created_at FROM task_events WHERE task_id = ? AND kind = 'dispatch_released'
|
|
1344
|
+
AND json_extract(payload, '$.ended_run') = ? ORDER BY id DESC LIMIT 1`).get(row.task_id, row.id);
|
|
1345
|
+
const intent = json(release?.payload, null);
|
|
1346
|
+
if (intent?.reason !== 'explicit set') return null;
|
|
1347
|
+
if (intent.abandon_waiting_input === true && Object.hasOwn(intent, 'reassigned_to') && intent.reassigned_from === row.profile
|
|
1348
|
+
&& intent.reassigned_to !== intent.reassigned_from) return { release_event: release.id, assignment_event: null };
|
|
1349
|
+
// Older writers recorded the role-authorized release and assignment separately.
|
|
1350
|
+
// Require the same actor, exact ended run and end timestamp; a model change is insufficient.
|
|
1351
|
+
const assignment = db.prepare(`SELECT id, payload, created_at FROM task_events WHERE task_id = ? AND run_id = ?
|
|
1352
|
+
AND kind = 'assigned' AND id < ? ORDER BY id DESC LIMIT 1`).get(row.task_id, row.id, release.id);
|
|
1353
|
+
const changed = json(assignment?.payload, null);
|
|
1354
|
+
if (!changed?.by || !Object.hasOwn(changed, 'assignee') || changed.by !== intent.by || changed.assignee === row.profile
|
|
1355
|
+
|| assignment.created_at !== row.ended_at || release.created_at < assignment.created_at
|
|
1356
|
+
|| release.created_at > assignment.created_at + 1) return null;
|
|
1357
|
+
return { release_event: release.id, assignment_event: assignment.id };
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function recordInputClose(board, row, session, reading) {
|
|
1362
|
+
return tx(board, (db) => {
|
|
1363
|
+
const latest = db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ? AND task_id = ?').get(row.id, row.task_id);
|
|
1364
|
+
const current = json(latest?.metadata, {})?.supercode;
|
|
1365
|
+
if (!latest?.ended_at || !current || current.pane !== session.pane || current.session_id !== session.session_id
|
|
1366
|
+
|| normalMachine(current.machine) !== normalMachine(session.machine) || !current.close_requested
|
|
1367
|
+
|| current.close_confirmed_at || current.close_transferred_to || current.close_claim?.lock !== claimer()
|
|
1368
|
+
|| !(Number(current.close_claim?.expires) > now())) return false;
|
|
1369
|
+
const protectedNow = db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL
|
|
1370
|
+
OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all().some((r) => {
|
|
1371
|
+
const s = json(r.metadata, {})?.supercode;
|
|
1372
|
+
return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine);
|
|
1373
|
+
});
|
|
1374
|
+
if (protectedNow) return false;
|
|
1375
|
+
const prior = current.close_input_reading;
|
|
1376
|
+
setRunSession(db, row.id, { close_input_reading: reading });
|
|
1377
|
+
if (prior?.release_event !== reading.release_event || prior?.session_id !== reading.session_id)
|
|
1378
|
+
event(db, row.task_id, 'session_input_close_authorized', reading, row.id);
|
|
1379
|
+
return true;
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1235
1383
|
/** The closes this process is making now, by run: each one's outcome, for a pass that meets it under way. */
|
|
1236
1384
|
const closesUnderway = new Map();
|
|
1237
1385
|
export async function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
1238
|
-
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
|
|
1386
|
+
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, profile, ended_at, metadata FROM task_runs
|
|
1239
1387
|
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
1240
1388
|
// Repair pre-transfer history once: only identities with an older unended reference need a write.
|
|
1241
1389
|
const latest = new Map(), transfers = new Map();
|
|
@@ -1255,7 +1403,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1255
1403
|
const panes = new Map();
|
|
1256
1404
|
for (const row of rows) {
|
|
1257
1405
|
let session = json(board.db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1258
|
-
if (!session || session.close_transferred_to || session.close_confirmed_at) continue;
|
|
1406
|
+
if (!session || detachedSession(board.db, session) || session.close_transferred_to || session.close_confirmed_at) continue;
|
|
1259
1407
|
// A keyed launch is reconciled by its machine's own daemon (D137), never by a door the close waits on. A session
|
|
1260
1408
|
// adopted onto a run whose launch was still pending did not come from that launch: the key is let go and the
|
|
1261
1409
|
// session is the run's, released or closed as its own below. A launch that never bound a pane left nothing the
|
|
@@ -1340,7 +1488,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1340
1488
|
try {
|
|
1341
1489
|
const claimed = tx(board, (db) => {
|
|
1342
1490
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1343
|
-
if (!current || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || claimHolds(current.close_claim)) return false;
|
|
1491
|
+
if (!current || detachedSession(db, current) || current.close_confirmed_at || current.close_transferred_to || current.pane !== session.pane || current.session_id !== session.session_id || claimHolds(current.close_claim)) return false;
|
|
1344
1492
|
requestSessionClose(db, row.task_id, { runId: row.id });
|
|
1345
1493
|
// a close this process already claims, with a minute of its claim left, is tried again without a new claim
|
|
1346
1494
|
const own = current.close_claim?.lock === claimer() && Number(current.close_claim.expires) > now() + 60;
|
|
@@ -1373,7 +1521,20 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1373
1521
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
1374
1522
|
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
1375
1523
|
// already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
|
|
1376
|
-
if (row.ended_at) {
|
|
1524
|
+
if (row.ended_at) {
|
|
1525
|
+
const { launchWindow, idleQuiet: quiet, interval } = boardSettings(workflowFor(board.root).params);
|
|
1526
|
+
const reading = await holdForTurn(session, observed.live.get(session.pane), {
|
|
1527
|
+
launchWindow, quiet, gap: Math.max(3 * interval, quiet / 2),
|
|
1528
|
+
inputAbandonment: reassignedInputClose(board, row, session),
|
|
1529
|
+
});
|
|
1530
|
+
if (reading?.stopped) {
|
|
1531
|
+
// the run says why its session stopped (a provider's limit, and when it resets), then the close proceeds
|
|
1532
|
+
const { reason, resets, observed_at } = reading.stopped;
|
|
1533
|
+
tx(board, (db) => event(db, row.task_id, 'run_incident', { observation: 'session_stopped', run: row.id, session: addressOf(session), pane: session.pane,
|
|
1534
|
+
reason: `${addressOf(session)} stopped at its harness's ${reason} prompt${resets ? `; it resets ${resets}` : ''}`, stopped: reason, resets, observed_at }, row.id));
|
|
1535
|
+
} else if (reading && !recordInputClose(board, row, session, reading))
|
|
1536
|
+
throw new Error('close deferred: ownership changed before ending its input dialog');
|
|
1537
|
+
}
|
|
1377
1538
|
await stopSession(board, session); observed.live.delete(session.pane);
|
|
1378
1539
|
}
|
|
1379
1540
|
}
|
|
@@ -1387,7 +1548,7 @@ export async function closeRequested(board, report, { taskId = null, caller = nu
|
|
|
1387
1548
|
}
|
|
1388
1549
|
tx(board, (db) => {
|
|
1389
1550
|
const current = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(row.id)?.metadata, {})?.supercode;
|
|
1390
|
-
if (!current || current.pane !== session.pane || current.session_id !== session.session_id) return;
|
|
1551
|
+
if (!current || detachedSession(db, current) || current.pane !== session.pane || current.session_id !== session.session_id) return;
|
|
1391
1552
|
const at = now();
|
|
1392
1553
|
// a close confirmed meanwhile (a claim that expired under a slow close) is not undone by this attempt's failure
|
|
1393
1554
|
if (error && current.close_confirmed_at) return;
|
|
@@ -1629,10 +1790,10 @@ function effectMessageKey(effect) {
|
|
|
1629
1790
|
*/
|
|
1630
1791
|
export async function sessionNotice(board, e) {
|
|
1631
1792
|
const prior = view(board, (db) => json(db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId)?.metadata, {})?.supercode);
|
|
1632
|
-
if (prior?.close_confirmed_at) {
|
|
1793
|
+
if (prior?.close_confirmed_at || view(board, db => detachedSession(db, prior))) {
|
|
1633
1794
|
tx(board, (db) => {
|
|
1634
1795
|
// A closed session has nothing to be told; its next run starts with its own prompt.
|
|
1635
|
-
event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId, reason: 'session closed' }, e.runId);
|
|
1796
|
+
event(db, e.id, 'session_notice_not_required', { address: e.address, verb: e.verb, notice: e.durableId, reason: prior?.detached_at ? 'session detached to a person' : 'session closed' }, e.runId);
|
|
1636
1797
|
if (prior.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
1637
1798
|
});
|
|
1638
1799
|
return true;
|
|
@@ -1643,6 +1804,7 @@ export async function sessionNotice(board, e) {
|
|
|
1643
1804
|
tx(board, (db) => {
|
|
1644
1805
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(e.runId)?.metadata, {})?.supercode;
|
|
1645
1806
|
if (session?.lifecycle_notice_pending === e.durableId) setRunSession(db, e.runId, { lifecycle_notice_pending: null });
|
|
1807
|
+
if (filed.detached) event(db, e.id, 'session_notice_not_required', { address, notice: e.durableId, reason: 'session detached to a person' }, e.runId);
|
|
1646
1808
|
if (filed.new) event(db, e.id, 'session_notice_filed', { address, verb: e.verb, source_event: e.sourceEvent, notice: e.durableId, message_id: filed.message_id }, e.runId);
|
|
1647
1809
|
});
|
|
1648
1810
|
return true;
|
|
@@ -1653,6 +1815,7 @@ export async function sessionEffect(board, e) {
|
|
|
1653
1815
|
if (e.kind === 'send_to_actor') {
|
|
1654
1816
|
const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
|
|
1655
1817
|
const session = json(row?.metadata, {})?.supercode;
|
|
1818
|
+
if (view(board, db => detachedSession(db, session))) return true;
|
|
1656
1819
|
const address = addressOf(session);
|
|
1657
1820
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1658
1821
|
// filed in the actor's mailbox, which delivers it
|
|
@@ -1842,9 +2005,8 @@ function bindLaunch(board, row, session, pane, sid) {
|
|
|
1842
2005
|
...(address ? { address, launch_pending: false, launch_error: null, seen_at: now() } : {}) });
|
|
1843
2006
|
if (current.ended_at) requestSessionClose(db, row.task_id, { runId: row.id });
|
|
1844
2007
|
else if (address && getTask(db, row.task_id)?.current_run_id === row.id) {
|
|
1845
|
-
|
|
2008
|
+
replaceWorkerSubscriptions(db, row.task_id, address);
|
|
1846
2009
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id=?').all(row.task_id)) subscribeIn(db, p.parent_id, address, 'dependency');
|
|
1847
|
-
for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of=?').all(row.task_id)) subscribeIn(db, t.task_id, address, 'worker');
|
|
1848
2010
|
}
|
|
1849
2011
|
if (sid && saved.launch_pending) event(db, row.task_id, 'session_launch_reconciled', { key: session.launch_key, pane, session_id: sid, role: saved.role }, row.id);
|
|
1850
2012
|
return true;
|
|
@@ -1909,7 +2071,7 @@ async function synchronizeAgents(board, report) {
|
|
|
1909
2071
|
.map(row=>{const session=json(row.metadata,{})?.supercode;return [session?.session_id?addressOf(session):`run:${row.id}`,row];})).values()];
|
|
1910
2072
|
for(const row of agentRuns){
|
|
1911
2073
|
const session=json(row.metadata,{})?.supercode;
|
|
1912
|
-
if(!session?.session_id||!session.agent_org)continue;
|
|
2074
|
+
if(!session?.session_id||!session.agent_org||view(board,db=>detachedSession(db,session)))continue;
|
|
1913
2075
|
const ref=`${session.harness}:${session.session_id}`;
|
|
1914
2076
|
// An ended run whose session the agent store already holds for another agent lost that claim when it ran: no round
|
|
1915
2077
|
// can change an ended run, so the disagreement is history. It is said once on its card (agent_ownership_conflict)
|
|
@@ -1932,7 +2094,7 @@ async function synchronizeAgents(board, report) {
|
|
|
1932
2094
|
let read=0;
|
|
1933
2095
|
for(const row of runsAwaitingUsage(board.root,org)){
|
|
1934
2096
|
const run=agentRuns.find(item=>String(item.id)===String(row.run_id));
|
|
1935
|
-
if(!run?.ended_at||read>=5)continue;
|
|
2097
|
+
if(!run?.ended_at||read>=5||view(board,db=>detachedSession(db,json(run.metadata,{})?.supercode)))continue;
|
|
1936
2098
|
await readRunUsage(board.root,org,row,run.ended_at);read++;
|
|
1937
2099
|
}
|
|
1938
2100
|
}
|
|
@@ -2209,11 +2371,10 @@ async function launchPane(board, task, runId, lane, fleet, { input = null, slot
|
|
|
2209
2371
|
// the working session hears updates to its card; the slot's previous session no longer does (a reviewer starting
|
|
2210
2372
|
// leaves the implementer's, and a subtask's arc session's, subscription alone)
|
|
2211
2373
|
const before = prev ? addressOf(prev) : null;
|
|
2212
|
-
if (before && before !== session.address) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ?").run(task.id, before);
|
|
2213
|
-
|
|
2374
|
+
if (before && before !== session.address) db.prepare("DELETE FROM kanban_notify_subs WHERE task_id = ? AND platform = 'supercode' AND chat_id = ? AND json_extract(delivery_metadata,'$.role')='worker'").run(task.id, before);
|
|
2375
|
+
replaceWorkerSubscriptions(db, task.id, session.address);
|
|
2214
2376
|
// it sees the cards its own depends on change, and works its subtasks
|
|
2215
2377
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(task.id)) subscribeIn(db, p.parent_id, session.address, 'dependency');
|
|
2216
|
-
for (const t of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of = ?').all(task.id)) subscribeIn(db, t.task_id, session.address, 'worker');
|
|
2217
2378
|
return true;
|
|
2218
2379
|
});
|
|
2219
2380
|
if (!recorded) return { ...session, mode: 'finished_during_launch' };
|
|
@@ -2464,6 +2625,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2464
2625
|
const native = found.pane ? null : nativeReading(await readNative(host, harness, sessionId), { session_id: sessionId, harness });
|
|
2465
2626
|
if (found.pane ? !await panePresent(host, found.pane) : native.alive !== true) throw new Error(`${found.address} has no verified live native process or pane`);
|
|
2466
2627
|
const beforeRun = view(board, (db) => currentRun(db, id));
|
|
2628
|
+
const beforeCustody = view(board, db => JSON.stringify(detachedCard(db, id)));
|
|
2467
2629
|
const beforeSession = beforeRun?.metadata?.supercode;
|
|
2468
2630
|
if (beforeRun && beforeSession?.session_id !== sessionId) throw new Error(`${id} has another active run/session; end it before adopting a different identity`);
|
|
2469
2631
|
if (beforeSession?.pane && beforeSession.pane !== found.pane && await panePresent(beforeSession.machine, beforeSession.pane)) throw new Error(`${id} still has live pane ${beforeSession.pane}; close the extra pane before rebinding ${sessionId}`);
|
|
@@ -2489,6 +2651,7 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2489
2651
|
const stale = (db) => {
|
|
2490
2652
|
if (request !== null && !adoptionOpenTo(db, id, request, open)) return Object.assign(new Error(`${id}'s adoption request was answered or claimed meanwhile`), { superseded: true });
|
|
2491
2653
|
if ((getTask(db, id)?.current_run_id ?? null) !== (beforeRun?.id ?? null)) return new Error(`${id} changed runs during adoption; retry against its current run`);
|
|
2654
|
+
if (JSON.stringify(detachedCard(db, id)) !== beforeCustody) return new Error(`${id} changed session custody during adoption; request adoption again`);
|
|
2492
2655
|
return null;
|
|
2493
2656
|
};
|
|
2494
2657
|
if (request !== null) tx(board, (db) => {
|
|
@@ -2529,14 +2692,19 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2529
2692
|
const expected = runSpec(entry, statusOf(db, task), task);
|
|
2530
2693
|
if (role && role !== expected?.role) throw new Error(`${id} needs its ${expected?.role ?? 'declared'} role, not ${role}`);
|
|
2531
2694
|
if (expected?.role === 'reviewer') {
|
|
2532
|
-
|
|
2695
|
+
// Independence reads history even when its implementer's custody was handed back.
|
|
2696
|
+
const implementer = json(db.prepare(`SELECT metadata FROM task_runs WHERE task_id=?
|
|
2697
|
+
AND COALESCE(json_extract(metadata,'$.supercode.slot'),'worker')='worker'
|
|
2698
|
+
AND json_extract(metadata,'$.supercode.session_id') IS NOT NULL ORDER BY id DESC LIMIT 1`).get(id)?.metadata, {})?.supercode;
|
|
2533
2699
|
if (implementer?.session_id === sessionId) throw new Error('a reviewer must be independent of the implementer session');
|
|
2534
2700
|
}
|
|
2701
|
+
takeSessionCustody(db, id, found.address);
|
|
2535
2702
|
event(db, id, 'dispatch_released', { reason: 'verified adoption', session: found.address });
|
|
2536
2703
|
// the manager's adoption is the workflow's own `adopt` verb where it declares one, sent as the manager; else its
|
|
2537
2704
|
// dispatch start, which the platform sends for a verified adoption
|
|
2538
2705
|
if (!beforeRun) { const verb = adoptionVerb(entry);
|
|
2539
|
-
fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() }
|
|
2706
|
+
fire(db, board, entry, id, verb, { roles: [verb === 'adopt' ? 'manager' : 'platform'], payload: { lock: claimer() },
|
|
2707
|
+
noticeSession: { address: found.address, session_id: sessionId, machine: host, harness } }); }
|
|
2540
2708
|
const runId = getTask(db, id).current_run_id;
|
|
2541
2709
|
if (!runId) throw new Error(`${id} waits on parents that are not done`);
|
|
2542
2710
|
// the agent joined above is the one of the run's own role; a run of another role would name the wrong agent
|
|
@@ -2546,13 +2714,23 @@ async function adoptSession(board, id, who, { request = null, open = request, pr
|
|
|
2546
2714
|
...(agent ? { agent_id: agent.identity.id, agent_org: team.org, agent_context: context ?? null,
|
|
2547
2715
|
agent_profile_intent: { ...structuredClone(agent.intended.resolved), worker: { ...agent.intended.resolved?.worker, harness: lane.harness, model: lane.model ?? null, cwd: dir ?? task.workspace_path ?? null } } } : {}) };
|
|
2548
2716
|
// a deferred launch was never issued: the adopted session is the run's, and no later round launches another
|
|
2549
|
-
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, launch_deferred: false, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
|
|
2717
|
+
setRunSession(db, runId, { ...(currentRun(db, id)?.metadata?.supercode ?? {}), ...session, detached_at: null, detached_to: null, launch_deferred: false, slot: currentRun(db, id)?.metadata?.supercode?.slot ?? beforeSession?.slot ?? expected?.session ?? 'worker', close_requested: null, closed_at: null, close_confirmed_at: null, close_error: null, missing_since: null });
|
|
2550
2718
|
event(db, id, 'session_adopted', { session_id: sessionId, machine: host, pane: session.pane, address: found.address, mode: 'adopt', role: currentRun(db, id)?.metadata?.supercode?.role }, runId);
|
|
2551
|
-
|
|
2719
|
+
replaceWorkerSubscriptions(db, id, found.address);
|
|
2552
2720
|
for (const p of db.prepare('SELECT parent_id FROM task_links WHERE child_id = ?').all(id)) subscribeIn(db, p.parent_id, found.address, 'dependency');
|
|
2553
2721
|
return { ...session, run: runId, workspace: dir };
|
|
2554
2722
|
});
|
|
2555
|
-
if (agent)
|
|
2723
|
+
if (agent) {
|
|
2724
|
+
recordAgentRun(board.root, team.org, agent.identity.id, { session: `${harness}:${sessionId}`, machine: host, run: adopted.run });
|
|
2725
|
+
// Teams learns whose the session is the way a launch tells it (ADR 0013, "launched or adopted"): the session's machine
|
|
2726
|
+
// queues it with the same agent environment a launch carries, and binds it when Teams answers. The adoption stands
|
|
2727
|
+
// either way; the card says whether the machine took it.
|
|
2728
|
+
const environment = agentEnvironment(agent, { root: board.root, context: normalMachine(host) === localMachine() ? localReaderContext(team.org, agent.identity.id) ?? undefined : undefined });
|
|
2729
|
+
const r = await doorOn(host, 'harness.v1.sessions.own', { session: `${harness}:${sessionId}`, environment }, { timeout: 30_000 });
|
|
2730
|
+
const answer = r.code === 0 ? lastJson(r.stdout) : null;
|
|
2731
|
+
tx(board, (db) => event(db, id, answer?.queued === true ? 'agent_session_queued' : 'agent_session_unqueued',
|
|
2732
|
+
{ session: found.address, agent: agent.identity.id, ...(answer?.queued === true ? {} : { reason: String(answer?.reason ?? (r.stderr || r.stdout || 'the machine did not answer')).slice(0, 300) }) }, adopted.run));
|
|
2733
|
+
}
|
|
2556
2734
|
return adopted;
|
|
2557
2735
|
}
|
|
2558
2736
|
|
|
@@ -2565,7 +2743,7 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
2565
2743
|
const panes = new Map();
|
|
2566
2744
|
for (const row of rows) {
|
|
2567
2745
|
const saved = json(row.metadata,{})?.supercode;
|
|
2568
|
-
if (!saved?.session_id || saved.forgotten || saved.close_transferred_to) continue;
|
|
2746
|
+
if (!saved?.session_id || saved.forgotten || saved.close_transferred_to || view(board, db => detachedCard(db, row.task_id) || detachedSession(db, saved))) continue;
|
|
2569
2747
|
if (!saved.monitor_held && !['crashed','closed','reclaimed','failed'].includes(row.outcome)) continue;
|
|
2570
2748
|
const live = fleet.sessions?.find(s => s.address === addressOf(saved) && s.state === 'running' && s.pane);
|
|
2571
2749
|
if (!live) continue;
|
|
@@ -2581,7 +2759,7 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
2581
2759
|
tx(board, db => {
|
|
2582
2760
|
const task = getTask(db,row.task_id), scope = scopeOf(db,board,task,{params:entry.params});
|
|
2583
2761
|
const automaticPause = scope.card.paused?.reason?.startsWith('its session ended cleanly (');
|
|
2584
|
-
if (task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
|
|
2762
|
+
if (task.detached || detachedSession(db, saved) || task.current_run_id || (scope.card.paused && !automaticPause) || scope.card.blocked_by.length || scope.card.archived || statusOf(db,task) !== saved.status) return;
|
|
2585
2763
|
if (automaticPause) db.prepare('UPDATE supercode_cards SET paused=NULL WHERE task_id=?').run(row.task_id);
|
|
2586
2764
|
const session = { ...saved,pane,mode:'manual-continuation',state:entry.workflow.run?.initial ?? 'active',monitor_held:false,
|
|
2587
2765
|
prior_intervals:[...(saved.prior_intervals??[]),{ended_at:row.ended_at,outcome:row.outcome,error:row.error}],
|
|
@@ -2594,8 +2772,7 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
2594
2772
|
db.prepare('UPDATE tasks SET current_run_id=?,claim_lock=?,claim_expires=? WHERE id=?').run(row.id,claimer(),now()+900,row.task_id);
|
|
2595
2773
|
projectStatus(db,board,entry,row.task_id);
|
|
2596
2774
|
event(db,row.task_id,'dispatch_released',{reason:'verified manual continuation',session:addressOf(session),incident:saved.incident??null});
|
|
2597
|
-
|
|
2598
|
-
for (const sub of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of=?').all(row.task_id)) subscribeIn(db,sub.task_id,addressOf(session),'worker');
|
|
2775
|
+
replaceWorkerSubscriptions(db,row.task_id,addressOf(session));
|
|
2599
2776
|
(report.continued ??= []).push([row.task_id,addressOf(session),pane]);
|
|
2600
2777
|
});
|
|
2601
2778
|
}
|
|
@@ -2613,18 +2790,33 @@ async function reconnectContinuedSessions(board, fleet, entry, report) {
|
|
|
2613
2790
|
* A question the board waits on (`ask`) is answered by a reply to the board's address; a notice is not. Throws only
|
|
2614
2791
|
* when the filing failed.
|
|
2615
2792
|
*/
|
|
2616
|
-
async function fileMail(board, to, body, key, { ask = false, wake = true, subject } = {}) {
|
|
2793
|
+
async function fileMail(board, to, body, key, { ask = false, wake = true, subject, eligible = null } = {}) {
|
|
2617
2794
|
if (boardCopyOf(board)) throw copyRefusal(board, 'files no mail for sessions or managers');
|
|
2618
|
-
const
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2795
|
+
const perform = async () => {
|
|
2796
|
+
if (eligible && !eligible()) return { filed: false, new: false, skipped: true };
|
|
2797
|
+
const request = {
|
|
2798
|
+
from: boardAddress(board.label), from_name: `${board.label} board`, to, id: key, wake, notice: !ask, subject: String(subject ?? String(body).split('\n')[0]).slice(0, 160),
|
|
2799
|
+
body: ask ? `[A question from the ${board.label} board] ${body}` : `[Board notice from the ${board.label} board] ${body}\n\n(An automated notice from the board's dispatcher; replies to this address go nowhere.)`,
|
|
2800
|
+
// the board's own Teams context (the call it files in): the sender is observed in that team, never the daemon's
|
|
2801
|
+
context: current().facts.SUPERCODE_CONTEXT ?? null,
|
|
2802
|
+
};
|
|
2803
|
+
const r = await runtimeDoor(null, 'harness.v1.mail.file', request, { timeout: 30_000 });
|
|
2804
|
+
const answer = r.code === 0 ? lastJson(r.stdout) : null;
|
|
2805
|
+
if (answer?.code !== 0) throw new Error((answer?.text || (lastJson(r.stderr)?.message ?? r.stderr) || 'no answer from the mailbox\'s filing door').trim().slice(-300));
|
|
2806
|
+
return { filed: true, message_id: answer.message_id, new: answer.new === true };
|
|
2623
2807
|
};
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2808
|
+
// Dispatcher mail reserves this exact identity before entering the mailbox door.
|
|
2809
|
+
if (!board.db) throw new Error('mail filing requires the board custody store');
|
|
2810
|
+
try { return await sessionOperation(board, { address: to, kind: 'mail filing' }, perform); }
|
|
2811
|
+
catch (error) {
|
|
2812
|
+
if (!error.sessionDetached) throw error;
|
|
2813
|
+
return { filed: false, new: false, detached: true };
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
/** Fleet listener mail uses the owner's ordinary filing door, including board-copy refusal and sender custody. */
|
|
2818
|
+
export function fileFleetNotice(root, to, body, key) {
|
|
2819
|
+
return fileMail({ root, label: boardLabel(root, 'default') }, to, body, key);
|
|
2628
2820
|
}
|
|
2629
2821
|
|
|
2630
2822
|
/**
|
|
@@ -2636,9 +2828,15 @@ export async function alertManagers(root, slug, text, key) {
|
|
|
2636
2828
|
try { const entry = await loadWorkflow(root); managers = Array.isArray(entry.params.managers) ? entry.params.managers.map(String) : []; } catch { /* a workflow that cannot load names nobody */ }
|
|
2637
2829
|
const label = boardLabel(root, slug);
|
|
2638
2830
|
let told = 0;
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
}
|
|
2831
|
+
if (!managers.length) return told;
|
|
2832
|
+
let board;
|
|
2833
|
+
try { board = openBoard(root, slug); board.label = label; }
|
|
2834
|
+
catch { return told; } // an unavailable store grants no permission to wake a session
|
|
2835
|
+
try {
|
|
2836
|
+
for (const to of managers) {
|
|
2837
|
+
try { const filed = await fileMail(board, to, text, key, { subject: text.split('\n')[0] }); if (filed.filed) told += 1; } catch { /* a filing that failed here is said by the next alert */ }
|
|
2838
|
+
}
|
|
2839
|
+
} finally { board.close(); }
|
|
2642
2840
|
return told;
|
|
2643
2841
|
}
|
|
2644
2842
|
|
|
@@ -2667,9 +2865,13 @@ export async function fileBoardMail(board, report, taskId = null) {
|
|
|
2667
2865
|
const subject = mail.requester ? `State requests · ${mail.members.length} request(s) · ${task?.title || first.task_id}` : `${task?.title || first.task_id} · ${mail.message_id ? 'Question' : 'Update'}`;
|
|
2668
2866
|
const filed = await fileMail(board, mail.to, mail.text, mail.message_id ?? `board-message-${first.id}`, { ask: Boolean(mail.message_id), subject });
|
|
2669
2867
|
tx(board, db => {
|
|
2868
|
+
if (filed.detached) {
|
|
2869
|
+
for (const member of mail.members) event(db, member.task_id, 'board_message_passed', { event: member.id, to: mail.to, reason: 'session detached to a person' });
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2670
2872
|
for (const member of mail.members) if (!db.prepare("SELECT 1 FROM task_events WHERE kind='board_message_sent' AND task_id=? AND json_extract(payload,'$.event')=?").get(member.task_id, member.id)) event(db, member.task_id, 'board_message_sent', { event: member.id, to: mail.to, message_id: filed.message_id, ...(mail.message_id ? { request_id: member.message_id ?? mail.message_id } : {}) });
|
|
2671
2873
|
});
|
|
2672
|
-
(report.mailed ??= []).push([first.task_id, mail.to]);
|
|
2874
|
+
if (filed.filed) (report.mailed ??= []).push([first.task_id, mail.to]);
|
|
2673
2875
|
} catch (error) {
|
|
2674
2876
|
for (const member of mail.members) report.undelivered.push([member.task_id, mail.to, String(error.message).slice(-200)]);
|
|
2675
2877
|
}
|
|
@@ -2710,8 +2912,7 @@ function ensureSubscriptions(board, entry) {
|
|
|
2710
2912
|
const session = json(db.prepare('SELECT metadata FROM task_runs WHERE id = ?').get(t.current_run_id)?.metadata, {})?.supercode;
|
|
2711
2913
|
const address = addressOf(session);
|
|
2712
2914
|
if (!address || (session.slot ?? 'worker') !== 'worker') continue;
|
|
2713
|
-
|
|
2714
|
-
for (const st of db.prepare('SELECT task_id FROM supercode_cards WHERE subtask_of = ?').all(t.id)) subscribeIn(db, st.task_id, address, 'worker');
|
|
2915
|
+
replaceWorkerSubscriptions(db, t.id, address);
|
|
2715
2916
|
}
|
|
2716
2917
|
});
|
|
2717
2918
|
}
|
|
@@ -2794,18 +2995,29 @@ async function notify(board, fleet, report, entry) {
|
|
|
2794
2995
|
for (const sub of subscriptions(board.db).filter((s) => s.platform === 'supercode')) {
|
|
2795
2996
|
await yieldToOwner();
|
|
2796
2997
|
if (stopping) break;
|
|
2998
|
+
if (view(board, db => personCustody(db, sub.chat_id))) continue;
|
|
2797
2999
|
if (sub.delivery_metadata?.dependent_card && sub.delivery_metadata.waiting_recipient) continue;
|
|
2798
3000
|
const role = ['worker', 'dependency', 'manager'].includes(sub.delivery_metadata?.role) ? sub.delivery_metadata.role : 'creator';
|
|
3001
|
+
// Native notices wait with their cursor intact until their own door proves
|
|
3002
|
+
// liveness. A failed fleet reading supplies no permission to file them.
|
|
3003
|
+
// Agent, board and operator mailboxes route independently of a native PID.
|
|
3004
|
+
if (sessionAt(sub.chat_id)) {
|
|
3005
|
+
const alive = fleet.ok ? await subscriberAlive(fleet, sub.chat_id) : null;
|
|
3006
|
+
if (alive !== true) {
|
|
3007
|
+
report.undelivered.push([sub.task_id, sub.chat_id, alive === false ? 'its session is not running; its notices wait' : 'its liveness could not be read; its notices wait until it can be']);
|
|
3008
|
+
continue;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
2799
3011
|
// a session the fleet does not list this pass is told next pass: one reading is not a loss (a loaded box drops
|
|
2800
3012
|
// sessions from a read, and a dropped subscription never came back), and a session that is replaced on its card
|
|
2801
3013
|
// has its subscription moved when the new one starts
|
|
2802
3014
|
const ended = recordedEnded(record, sub.chat_id);
|
|
2803
|
-
if ((role !== 'creator' || ended) && fleet.ok) {
|
|
3015
|
+
if (!sessionAt(sub.chat_id) && (role !== 'creator' || ended) && fleet.ok) {
|
|
2804
3016
|
const alive = await subscriberAlive(fleet, sub.chat_id);
|
|
2805
3017
|
if (alive === false) continue;
|
|
2806
3018
|
// unknown is not absent: its notices wait, and the wait is said, until its liveness can be read
|
|
2807
3019
|
if (alive === null) { report.undelivered.push([sub.task_id, sub.chat_id, 'its liveness could not be read; its notices wait until it can be']); continue; }
|
|
2808
|
-
} else if (ended) continue; //
|
|
3020
|
+
} else if (!sessionAt(sub.chat_id) && ended) continue; // native sessions already required a current live observation
|
|
2809
3021
|
const kinds = heardBy(entry, role);
|
|
2810
3022
|
if (!kinds.length) continue;
|
|
2811
3023
|
// The retained cursor resumes the next page; one native read never materializes the whole unread history.
|
|
@@ -2847,11 +3059,21 @@ async function notify(board, fleet, report, entry) {
|
|
|
2847
3059
|
text = c ? noticeOf(entry, role, ev.kind, task, p, c) : null;
|
|
2848
3060
|
} else text = noticeOf(entry, role, ev.kind, task, p);
|
|
2849
3061
|
if (text) {
|
|
3062
|
+
// A verified binding may have replaced this audience while liveness
|
|
3063
|
+
// or an earlier filing awaited a door. Re-read before every filing.
|
|
3064
|
+
const currentSub = board.db.prepare(`SELECT last_event_id,delivery_metadata FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3065
|
+
const currentRole = json(currentSub?.delivery_metadata, {})?.role ?? 'creator';
|
|
3066
|
+
if (!currentSub || currentRole !== role) break;
|
|
3067
|
+
if (Number(currentSub.last_event_id) >= ev.id) { cursor = Math.max(cursor, Number(currentSub.last_event_id)); continue; }
|
|
2850
3068
|
const body = ['worker', 'dependency'].includes(role) ? identifyNotice(ev, text) : text;
|
|
2851
3069
|
// filed under its fixed key: a second pass filing the same notice files nothing new
|
|
2852
|
-
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}
|
|
3070
|
+
const filed = await fileMail(board, sub.chat_id, body, `board-${sub.task_id}-${ev.id}-${sub.chat_id.slice(-12)}`, { wake, subject: `${ev.kind.replaceAll('_', ' ')} · run ${ev.run_id ?? 'none'} · event ${ev.id} · ${task?.title || sub.task_id}`,
|
|
3071
|
+
eligible: () => view(board, db => {
|
|
3072
|
+
const currentSub = db.prepare(`SELECT last_event_id FROM kanban_notify_subs ${where}`).get(...identity);
|
|
3073
|
+
return Boolean(currentSub) && Number(currentSub.last_event_id) < ev.id;
|
|
3074
|
+
}) });
|
|
2853
3075
|
if (filed.new) tx(board, (db) => event(db, sub.task_id, 'notice_filed', { source_event: ev.id, kind: ev.kind, address: sub.chat_id, message_id: filed.message_id, wake }));
|
|
2854
|
-
report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
3076
|
+
if (filed.filed) report.notified.push([sub.task_id, ev.kind, sub.chat_id]);
|
|
2855
3077
|
}
|
|
2856
3078
|
cursor = ev.id;
|
|
2857
3079
|
forward(cursor);
|
|
@@ -3163,7 +3385,7 @@ async function activatePullRequestReviews(board, entry, report, effects, { only
|
|
|
3163
3385
|
if (!task) return false;
|
|
3164
3386
|
// a status the platform cannot request a review from needs no scope built to say so
|
|
3165
3387
|
if (!transitionsFor(entry.workflow, statusOf(db, task), 'request_review').some(t => !t.by || t.by.includes('platform'))) return false;
|
|
3166
|
-
const scope = scopeOf(db, board, task, { params: entry.params });
|
|
3388
|
+
const scope = scopeOf(db, board, task, { params: entry.params, lazy: true });
|
|
3167
3389
|
if (scope.card.dispatch_hold || scope.card.paused || scope.card.blocked_by.length) return false;
|
|
3168
3390
|
// The card's Tasks are its subtasks; an open one holds the review, and says so.
|
|
3169
3391
|
const open = scope.card.subtasks.filter(t => !['done', 'cancelled'].includes(t.status));
|