@volter/supercode-orchestrator 0.5.228 → 0.5.229
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/activation.mjs +1 -1
- package/adapters/agent-mailbox.mjs +10 -0
- package/adapters/api-server.mjs +6 -0
- package/agents.mjs +149 -40
- package/apply/doors.mjs +3 -1
- package/bin/agent-channel.mjs +7 -2
- package/bin/connection-runner.mjs +60 -19
- package/bin/hermes-worker.mjs +2 -1
- package/bin/orchestrator.mjs +3 -3
- package/bin/runtime.mjs +3 -2
- package/board/agent-current.mjs +82 -0
- package/board/agent-declarations.mjs +10 -3
- package/board/args.mjs +137 -6
- package/board/call.mjs +16 -1
- package/board/caller-session.mjs +3 -1
- package/board/capacity.mjs +59 -45
- package/board/card-state.mjs +14 -5
- package/board/ci.mjs +521 -178
- package/board/cli.mjs +614 -209
- package/board/client.mjs +64 -21
- package/board/close-current.mjs +36 -0
- package/board/current-records.mjs +44 -0
- package/board/custody.mjs +2 -1
- package/board/dispatch.mjs +1973 -846
- package/board/doors.mjs +2 -2
- package/board/engine.mjs +623 -85
- package/board/event-stream.mjs +10 -5
- package/board/fence.mjs +2 -0
- package/board/files.mjs +93 -62
- package/board/generation-owner.mjs +188 -53
- package/board/git-document.mjs +3 -1
- package/board/github-rest.mjs +29 -7
- package/board/incidents.mjs +5 -17
- package/board/listing.mjs +14 -8
- package/board/local-admission.mjs +59 -0
- package/board/lock-holder.mjs +18 -6
- package/board/machine-fleet.mjs +66 -13
- package/board/owner-door-worker.mjs +3 -3
- package/board/owner-door.mjs +17 -7
- package/board/owner-health.mjs +12 -15
- package/board/owner-interface.mjs +6 -0
- package/board/publication-reader.mjs +50 -0
- package/board/published.mjs +40 -12
- package/board/queries.mjs +24 -4
- package/board/record-view.mjs +30 -3
- package/board/request-mail.mjs +37 -6
- package/board/resting-admission.mjs +318 -0
- package/board/resting-codec-qualifications.json +33 -0
- package/board/resting-protocol.mjs +71 -0
- package/board/resting-state-protocol.json +120 -0
- package/board/resting-state.mjs +113 -0
- package/board/runtime-door.mjs +2 -2
- package/board/session-continuity.mjs +127 -0
- package/board/session-work.mjs +25 -13
- package/board/standalone-declaration.mjs +24 -0
- package/board/storage-contract.mjs +129 -7
- package/board/store.mjs +73 -11
- package/board/tools.mjs +56 -13
- package/board/wake.mjs +18 -5
- package/board/workflow.mjs +284 -82
- package/board/workspace.mjs +86 -9
- package/board/ztrack.mjs +344 -91
- package/connection.mjs +72 -17
- package/gates.mjs +2 -1
- package/hand-off.mjs +4 -3
- package/hermes-cli.mjs +5 -3
- package/hermes-locks.mjs +5 -2
- package/hosted.mjs +2 -1
- package/index.d.ts +2 -0
- package/index.mjs +1 -1
- package/kanban-worker.mjs +63 -13
- package/loop.mjs +33 -0
- package/mcp/hermes-kanban-tools.json +109 -50
- package/mcp/server.mjs +7 -1
- package/ontology.d.ts +26 -0
- package/orchestration.mjs +15 -4
- package/package.json +19 -12
- package/session-recovery.mjs +2 -1
- package/worker-route.mjs +37 -19
package/board/workflow.mjs
CHANGED
|
@@ -17,12 +17,18 @@ import { randomBytes } from 'node:crypto';
|
|
|
17
17
|
import { actorOf, appendRunObservation, boardKey, cardBranch, cardHandleOf, event as record, json, now } from './store.mjs';
|
|
18
18
|
import { releaseAllSideSessions, sideSessions } from './side-sessions.mjs';
|
|
19
19
|
import { evaluate, holds, render, needsSources } from './expr.mjs';
|
|
20
|
-
import { completionEvidence, declaredWorkBranches, namedWorkBranch, reviewCandidate } from './ci.mjs';
|
|
20
|
+
import { completionEvidence, declaredWorkBranches, namedWorkBranch, pendingReviewRequests, reviewCandidate } from './ci.mjs';
|
|
21
21
|
import { branchClaim, cardCommitsOn, isLocalWorkspace, isWorkspacePath, unrecordedMaking } from './workspace.mjs';
|
|
22
22
|
import { cardName, cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
23
|
-
import { latestEvent } from './queries.mjs';
|
|
23
|
+
import { dispatchWait, latestEvent } from './queries.mjs';
|
|
24
|
+
import { PAUSE_DEADLINE, RESTING_WRITE_MODE, assertRestingWrite, assertBacklogQualified, deadlineUntil } from './resting-state.mjs';
|
|
25
|
+
import { authorizeReplacement } from './session-continuity.mjs';
|
|
26
|
+
import { endedHere } from '../agents.mjs';
|
|
24
27
|
|
|
25
28
|
const loaded = new Map();
|
|
29
|
+
// Authority requests keep their named decision maker; ordinary missing detail stays with the requested act.
|
|
30
|
+
export const ownerRule = message => Object.assign(new Error(message), { ownerRule: true });
|
|
31
|
+
export const requestDetail = message => Object.assign(new Error(message), { boardDetail: true });
|
|
26
32
|
// the owner's read door holds the workflow the owner reads (owner-door.mjs), told when it is read again
|
|
27
33
|
const workflowWatchers = new Set();
|
|
28
34
|
const hold = (root, entry) => {
|
|
@@ -76,6 +82,7 @@ export async function loadWorkflow(root) {
|
|
|
76
82
|
// What the board's view of a home is decoded from: the workflow, each profile's config, credentials and the agent
|
|
77
83
|
// layer, persona, and which profiles there are. A session store or a run log changing is no change to it.
|
|
78
84
|
const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AGENTS\.md)|workflow\.yaml|profiles(?:\/[^/]+)?)$/;
|
|
85
|
+
const homeFollowers = new Map();
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
88
|
* Follow the home's own files, so the held view is read again on its next use after one it is decoded from changes:
|
|
@@ -85,6 +92,28 @@ const DECODED = /^(?:(?:profiles\/[^/]+\/)?(?:config\.yaml|\.env|agents\.json|AG
|
|
|
85
92
|
* Answers a stop.
|
|
86
93
|
*/
|
|
87
94
|
export function followHome(root, { onChange = () => {}, onLost = () => {} } = {}) {
|
|
95
|
+
let held = homeFollowers.get(root);
|
|
96
|
+
const follower = { onChange, onLost };
|
|
97
|
+
if (!held) {
|
|
98
|
+
held = { followers: new Set([follower]), stop: null };
|
|
99
|
+
homeFollowers.set(root, held);
|
|
100
|
+
try {
|
|
101
|
+
held.stop = watchHome(root, {
|
|
102
|
+
onChange: name => { for (const subscriber of held.followers) subscriber.onChange(name); },
|
|
103
|
+
onLost: error => { for (const subscriber of held.followers) subscriber.onLost(error); },
|
|
104
|
+
});
|
|
105
|
+
} catch (error) { homeFollowers.delete(root); throw error; }
|
|
106
|
+
} else held.followers.add(follower);
|
|
107
|
+
// Preparation and serving share one continuous watch. Attaching the serving callbacks must not invalidate the
|
|
108
|
+
// preparatory read: that read is already newer than this watch, and real file events alone make it stale.
|
|
109
|
+
return () => {
|
|
110
|
+
if (!held.followers.delete(follower) || held.followers.size) return;
|
|
111
|
+
held.stop();
|
|
112
|
+
if (homeFollowers.get(root) === held) homeFollowers.delete(root);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function watchHome(root, { onChange, onLost }) {
|
|
88
117
|
let watcher = null;
|
|
89
118
|
let stopped = false;
|
|
90
119
|
// A decoded file that is a link (a workflow kept in another checkout) is watched at its real target too, in the
|
|
@@ -236,7 +265,7 @@ function pattern(p) {
|
|
|
236
265
|
// running git here on a path of that machine (a Windows path read on a Mac answered no commits for as long as the card
|
|
237
266
|
// ran: volter-org t_9a9c2a9c). A folder that is not there, a git that did not answer, a machine that could not be asked:
|
|
238
267
|
// unknown, with why, and never "no commits".
|
|
239
|
-
const commitTimes = new Map(); // `${path}:${id}` → { at, known, value, error }
|
|
268
|
+
const commitTimes = new Map(); // `${path}:${id}` → { at, inputs, known, value, error }
|
|
240
269
|
// A commit names the card by its id or by its keyed number (`VO-856`); a branch is the card's by its handle too.
|
|
241
270
|
async function readCardCommit(path, id, branches, handle = null) {
|
|
242
271
|
if (!existsSync(path)) return { known: false, value: null, error: `no folder ${path} on this machine` };
|
|
@@ -283,14 +312,16 @@ async function readCardCommit(path, id, branches, handle = null) {
|
|
|
283
312
|
async function observeLastCommit(path, id, branches = [], machine = null, handle = null) {
|
|
284
313
|
if (!path) return null;
|
|
285
314
|
const key = `${path}:${id}`;
|
|
315
|
+
// Reuse a reading across rounds, while a changed declaration or machine is read immediately.
|
|
316
|
+
const inputs = JSON.stringify([machine, [...new Set(branches)].sort(), handle?.toUpperCase() ?? null]);
|
|
286
317
|
const cached = commitTimes.get(key);
|
|
287
|
-
if (cached && now() - cached.at < 15) return cached.value;
|
|
318
|
+
if (cached?.inputs === inputs && now() - cached.at < 15) return cached.value;
|
|
288
319
|
let reading;
|
|
289
320
|
try {
|
|
290
321
|
if (isLocalWorkspace({ machine })) reading = await readCardCommit(path, id, branches, handle);
|
|
291
322
|
else reading = { known: true, value: (await cardCommitsOn(machine, path, id, branches, handle?.toUpperCase() ?? null)).at };
|
|
292
323
|
} catch (error) { reading = { known: false, value: null, error: String(error?.message ?? error).slice(0, 300) }; }
|
|
293
|
-
commitTimes.set(key, { at: now(), ...reading });
|
|
324
|
+
commitTimes.set(key, { at: now(), inputs, ...reading });
|
|
294
325
|
return reading.value;
|
|
295
326
|
}
|
|
296
327
|
|
|
@@ -315,13 +346,15 @@ export function sessionProgress(db, task) {
|
|
|
315
346
|
const read = path ? commitTimes.get(`${path}:${task.id}`) ?? { known: false, error: 'its checkout has not been read yet' } : { known: true, value: null };
|
|
316
347
|
return { event: Number(event?.id ?? 0), commit: read.known ? Number(read.value ?? 0) : null, ...(read.known ? {} : { commit_unread: read.error ?? 'its checkout could not be read' }) };
|
|
317
348
|
}
|
|
318
|
-
/** Observe external facts before entering an optimistic board mutation.
|
|
349
|
+
/** Observe external facts before entering an optimistic board mutation. `commits: true` reads the card's commits
|
|
350
|
+
* fresh, as every decision on them must; `commits: 'cached'` is the idle pass's alone and reuses a reading under 15 s
|
|
351
|
+
* old with the same declaration, so a board of running cards does not spawn git for each card every round. */
|
|
319
352
|
export async function refreshCardInputs(board, id, { commits = false } = {}) {
|
|
320
353
|
const task=board.db.prepare('SELECT t.*,c.subtask_of FROM tasks t LEFT JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?').get(id);
|
|
321
354
|
if (!task)return;
|
|
322
355
|
if (commits && !task.subtask_of) {
|
|
323
356
|
const { path, machine } = commitSource(board.db, task);
|
|
324
|
-
if(path)commitTimes.delete(`${path}:${id}`);
|
|
357
|
+
if (path && commits !== 'cached') commitTimes.delete(`${path}:${id}`);
|
|
325
358
|
await observeLastCommit(path,id,declaredWorkBranches(board.db,id),machine,cardHandleOf(board,id));
|
|
326
359
|
}
|
|
327
360
|
}
|
|
@@ -471,8 +504,9 @@ function bodyBlocks(body) {
|
|
|
471
504
|
return blocks;
|
|
472
505
|
}
|
|
473
506
|
/** Whether `body` is `was` with only outcomes ticked: each changed outcome (a checkbox item under the Acceptance heading,
|
|
474
|
-
* read whole with its continuation lines) was `- [ ] X` and is now `- [x] X
|
|
475
|
-
* and is now `- [x] X: <pointer>` (the pointer a tick needs, added after it)
|
|
507
|
+
* read whole with its continuation lines) was `- [ ] X` and is now `- [x] X: <pointer>`, or was `- [x] X`
|
|
508
|
+
* and is now `- [x] X: <pointer>` (the pointer a tick needs, added after it). The tick and pointer go on the outcome's
|
|
509
|
+
* own line and its continuation lines stay unchanged; no other line added, removed or changed. */
|
|
476
510
|
function onlyTicks(was, body) {
|
|
477
511
|
if (typeof body !== 'string') return false;
|
|
478
512
|
const before = bodyBlocks(was);
|
|
@@ -483,13 +517,18 @@ function onlyTicks(was, body) {
|
|
|
483
517
|
const now = after[i];
|
|
484
518
|
if (block.lines.join('\n') === now.lines.join('\n')) return true;
|
|
485
519
|
if (block.section !== 'acceptance' || !block.item || !now.item) return false;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
520
|
+
// Outcome prose may contain colons. Preserve the entire old outcome and permit only an appended pointer.
|
|
521
|
+
const ticks = (old, text) => {
|
|
522
|
+
const pointed = (ticked) => text.startsWith(`${ticked}: `) && text.slice(ticked.length + 2).trim().length > 0;
|
|
523
|
+
if (TICKED_OUTCOME.test(old)) return pointed(old);
|
|
524
|
+
const open = OPEN_OUTCOME.exec(old);
|
|
525
|
+
if (!open) return false;
|
|
526
|
+
const ticked = `${open[1]}[x] ${open[2]}`;
|
|
527
|
+
return pointed(ticked);
|
|
528
|
+
};
|
|
529
|
+
// The tick and its pointer go on the outcome's own line, its continuation lines (its `- source:` lines) unchanged,
|
|
530
|
+
// as a bare outcome is ticked.
|
|
531
|
+
return block.lines.slice(1).join('\n') === now.lines.slice(1).join('\n') && ticks(block.lines[0], now.lines[0]);
|
|
493
532
|
});
|
|
494
533
|
}
|
|
495
534
|
|
|
@@ -540,10 +579,10 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
540
579
|
const coderProfile = once(() => entry?.profiles?.[codingProfile()]);
|
|
541
580
|
const configuredCoder = once(() => coderProfile()?.worker);
|
|
542
581
|
const changes = once(() => json(one(db, "SELECT payload FROM task_events WHERE task_id = ? AND kind = 'changes_requested' ORDER BY id DESC LIMIT 1", task.id)?.payload, {}) ?? {});
|
|
543
|
-
const interruption = once(() => json(
|
|
544
|
-
'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost'
|
|
545
|
-
|
|
546
|
-
const lastBlock = once(() =>
|
|
582
|
+
const interruption = once(() => json(latestEvent(db, task.id, ['blocked', 'block_loop_detected', 'dependency_wait', 'gave_up',
|
|
583
|
+
'unblocked', 'changes_requested', 'review_reopened', 'status', 'reclaimed', 'stale', 'timed_out', 'crashed', 'spawn_failed', 'rate_limited', 'session_lost'],
|
|
584
|
+
'payload')?.payload, {}) ?? {});
|
|
585
|
+
const lastBlock = once(() => latestEvent(db, task.id, ['blocked', 'unblocked', 'dependency_wait'], 'kind,payload'));
|
|
547
586
|
const waitingOn = once(() => lastBlock()?.kind === 'blocked' ? json(lastBlock().payload, {})?.waiting_on ?? null : null);
|
|
548
587
|
const currentRun = once(() => task.current_run_id ? one(db, 'SELECT metadata FROM task_runs WHERE id = ?', task.current_run_id) : null);
|
|
549
588
|
const session = once(() => json(currentRun()?.metadata, {})?.supercode ?? {});
|
|
@@ -560,6 +599,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
560
599
|
status,
|
|
561
600
|
blocked_by: blocks,
|
|
562
601
|
paused: json(state.paused, null),
|
|
602
|
+
backlog: json(state.backlog, null),
|
|
563
603
|
detached: json(state.detached, null),
|
|
564
604
|
// the person the card is assigned to (custody.mjs personOf), or null: a person's card runs no session and closes on
|
|
565
605
|
// that person's completion
|
|
@@ -592,8 +632,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
592
632
|
// `paused`: a paused subtask's pause (who and why), or null; a paused subtask holds no review of its arc
|
|
593
633
|
get subtasks() { return all(db, `SELECT t.id, ${related}, t.title, t.assignee, c.paused FROM tasks t JOIN supercode_cards c ON c.task_id = t.id WHERE c.subtask_of = ? ORDER BY t.created_at`, task.id)
|
|
594
634
|
.map((t) => ({ ...t, paused: json(t.paused, null) })); },
|
|
595
|
-
get queue_reason() { return
|
|
596
|
-
task.id, Number(one(db, 'SELECT since FROM supercode_cards WHERE task_id = ?', task.id)?.since ?? 0))?.payload, {})?.reason ?? null; },
|
|
635
|
+
get queue_reason() { return dispatchWait(db, task.id)?.reason ?? null; },
|
|
597
636
|
// the message whose answer the standing block waits for (`block --waiting-on`), and that answer's id once it has landed
|
|
598
637
|
get waiting_on() { return waitingOn(); },
|
|
599
638
|
get state_request() { return stateRequestOf(db, task.id); },
|
|
@@ -604,7 +643,7 @@ export function scopeOf(db, board, task, { params, event = {}, extra = {}, lazy
|
|
|
604
643
|
// whether an edit of its text only ticks outcome lines (the card's own session's door to its acceptance)
|
|
605
644
|
only_ticks: (body) => onlyTicks(task.body, body),
|
|
606
645
|
asked_of: () => askedOf(waitingOn()),
|
|
607
|
-
get last_block_event() { return
|
|
646
|
+
get last_block_event() { return latestEvent(db, task.id, ['blocked', 'unblocked'], 'kind')?.kind ?? null; },
|
|
608
647
|
// where the latest interruption says the card resumes (any status the workflow declares); none recorded: null
|
|
609
648
|
get resume_status() { const value = interruption(); return value.resume_status ?? value.retry_status ?? value.source_status ?? null; },
|
|
610
649
|
get last_run() { return lastRun() ?? {}; },
|
|
@@ -688,7 +727,7 @@ function queueTransitionNotice(db, board, entry, task, from, name, payload, befo
|
|
|
688
727
|
if (adopting) return;
|
|
689
728
|
const after = cardOf(db, task.id), to = statusOf(db, after);
|
|
690
729
|
const targetRun = beforeRun;
|
|
691
|
-
const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.archived, t.blocked_by]);
|
|
730
|
+
const state = (t, status) => JSON.stringify([status, t.current_run_id, t.paused, t.backlog, t.archived, t.blocked_by]);
|
|
692
731
|
const beforeState = json(beforeRun?.metadata, {})?.supercode?.state;
|
|
693
732
|
const afterState = beforeRun && json(one(db, 'SELECT metadata FROM task_runs WHERE id=?', beforeRun.id)?.metadata, {})?.supercode?.state;
|
|
694
733
|
if (!targetRun || (state(task, from) === state(after, to) && beforeState === afterState) || effects.some((e) => e.kind === 'session_notice' && e.runId === targetRun.id)) return;
|
|
@@ -756,24 +795,40 @@ function declaredStatus(db, board, entry, task) {
|
|
|
756
795
|
* `{ from, to, effects }`; throws the refusal when no transition applies or a validator fails.
|
|
757
796
|
*/
|
|
758
797
|
export function validateEvent(db, board, entry, id, name, { roles = [], payload = {}, extra = {}, localOnly = false } = {}) {
|
|
759
|
-
//
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if ((name === 'cancel' || name === 'archive') && !String(payload?.reason ?? '').trim()) throw new Error(`${id}: ${name} needs a reason (--reason <why>)`);
|
|
798
|
+
// Attribute an omitted reason to the request itself; never invent evidence about the work.
|
|
799
|
+
if ((name === 'cancel' || name === 'archive') && !String(payload?.reason ?? '').trim())
|
|
800
|
+
payload = { ...payload, reason: 'none given' };
|
|
763
801
|
// A card the document no longer lists (a removal the document cannot make) still takes the manager's cancel or
|
|
764
802
|
// archive: that ends it the way the document already has, and is the one way a manager clears it.
|
|
765
|
-
if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw
|
|
803
|
+
if (db.boardErrors?.has(id) && !(/cannot change card removal/.test(db.boardErrors.get(id)) && ['cancel', 'archive'].includes(name) && roles.includes('manager'))) throw requestDetail(`${id}: ${db.boardErrors.get(id)}`);
|
|
766
804
|
const { workflow, params } = entry;
|
|
767
805
|
if (workflow.run && (workflow.run.on?.[name] || Object.values(workflow.run.states ?? {}).some((s) => s.on?.[name]))) return null;
|
|
768
806
|
const task = cardOf(db, id);
|
|
769
807
|
const from = localOnly ? statusOf(db, task) : declaredStatus(db, board, entry, task);
|
|
808
|
+
// Normalize relative durations on the board clock, before workflow guards see the event.
|
|
809
|
+
if (name === 'pause') {
|
|
810
|
+
if (!roles.includes('manager')) throw ownerRule('only the manager pauses a card');
|
|
811
|
+
assertRestingWrite('paused', payload, task.paused);
|
|
812
|
+
if (RESTING_WRITE_MODE !== 'legacy-holds') payload = { ...payload, until: deadlineUntil(payload.until ?? '1h', now(), 'pause') };
|
|
813
|
+
}
|
|
814
|
+
if (name === 'backlog' || name === 'plan') {
|
|
815
|
+
if (name === 'backlog') assertRestingWrite('backlog', payload);
|
|
816
|
+
if (name === 'backlog' && ['done', 'archived', 'cancelled'].includes(storedAs(workflow, from))) throw requestDetail('an ended card cannot enter backlog');
|
|
817
|
+
if (name === 'backlog' && payload.pause_revision != null && !task.paused) throw requestDetail('backlog migration refused: the named pause is no longer current');
|
|
818
|
+
if (name === 'backlog' && task.paused) {
|
|
819
|
+
const revision = task.paused.generation ?? latestEvent(db, id, ['paused'], 'id')?.id;
|
|
820
|
+
if (revision == null || String(payload.pause_revision ?? '') !== String(revision)) throw requestDetail('backlog migration requires --pause-revision naming the current pause generation (or its legacy paused event id)');
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
const expiry = workflow.flags?.paused?.expires;
|
|
824
|
+
if (payload.expired_generation != null && !dueFlagExpiry(task, name, roles, payload, expiry)) throw requestDetail('pause expiry is stale, early, or not authorized by this workflow');
|
|
770
825
|
if (localOnly && !workflow.statuses[from]) return null; // only fire adopts an imported status
|
|
771
826
|
const candidates = transitionsFor(workflow, from, name);
|
|
772
827
|
// a refusal names the right form: each transition's description, its card placeholders filled with this card
|
|
773
828
|
const said = (t) => t.description ? String(t.description).replaceAll('<arc>', id).replaceAll('<card>', id) : null;
|
|
774
829
|
if (!candidates.length) {
|
|
775
830
|
const elsewhere = (workflow.verbs?.[name] ?? []).filter((t) => t.from?.length && t.description);
|
|
776
|
-
throw
|
|
831
|
+
throw requestDetail(`${id} is ${from}; ${name.replace(/_/g, '-')} does not apply there.${elsewhere.map((t) => ` From ${t.from.join(' or ')}: ${said(t)}`).join('')}`);
|
|
777
832
|
}
|
|
778
833
|
// `sender`: the roles the event was sent with (a validator can ask who is asking)
|
|
779
834
|
const scope = scopeOf(db, board, task, { params, event: payload, lazy: localOnly, deferSources: localOnly, extra: { lanes: lanesIn(entry), ...extra, sender: roles, verb: name } });
|
|
@@ -782,7 +837,7 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
782
837
|
const state = name === 'complete' ? 'done' : name === 'cancel' ? 'cancelled' : null;
|
|
783
838
|
const request = state && candidates.some(transition => transition.by?.includes('creator'))
|
|
784
839
|
? ` Ask the manager with workflow request-state ${id} --state ${state} --reason <why> --evidence <verdict-and-pointers>.` : '';
|
|
785
|
-
throw
|
|
840
|
+
throw ownerRule(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(candidates.flatMap((t) => t.by ?? []))].join(' or ')}. ${candidates.map(said).filter(Boolean).join(' ')}${request}`);
|
|
786
841
|
}
|
|
787
842
|
// A source-dependent branch is undecidable before observation; preflight must not choose its fallback.
|
|
788
843
|
const needsSource = expr => needsSources(expr, workflow.derived);
|
|
@@ -792,14 +847,12 @@ export function validateEvent(db, board, entry, id, name, { roles = [], payload
|
|
|
792
847
|
// none of the sender's rules holds, but another role's does (one verb with a rule per case, as `set` has one per
|
|
793
848
|
// field): it is that role's to send
|
|
794
849
|
const theirs = taken ? [] : candidates.filter((t) => !allowed.includes(t) && holds(t.guard ?? null, scope));
|
|
795
|
-
if (theirs.length) throw
|
|
850
|
+
if (theirs.length) throw ownerRule(`${id} is ${from}; ${name.replace(/_/g, '-')} there is for ${[...new Set(theirs.flatMap((t) => t.by ?? []))].join(' or ')}. ${theirs.map(said).filter(Boolean).join(' ')}${allowed.length ? ` Yours: ${allowed.map(said).filter(Boolean).join(' ')}` : ''}`);
|
|
796
851
|
if (!taken) {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
state.archived && 'archived'].filter(Boolean);
|
|
802
|
-
const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} cannot proceed: ${reasons.join('; ') || `guard ${allowed.map(t => t.guard).join(' | ')}`}`);
|
|
852
|
+
// Only the evaluated rules explain a refusal. Incidental state, such as a current run, need not be a condition
|
|
853
|
+
// of any rule and must not be reported as its cause.
|
|
854
|
+
const reasons = allowed.map(t => `failed rule ${t.guard}${said(t) ? ` (${said(t)})` : ''}`);
|
|
855
|
+
const none = new Error(`${id} is ${from}; ${name.replace(/_/g, '-')} cannot proceed: ${reasons.join(' | ')}`);
|
|
803
856
|
none.noTransition = true; throw none;
|
|
804
857
|
}
|
|
805
858
|
for (const v of taken.validate ?? []) {
|
|
@@ -822,13 +875,18 @@ export function fire(db, board, entry, id, name, { roles = [], payload = {}, ext
|
|
|
822
875
|
if (name === 'specify' && taken.target && (entry.workflow.statuses[from]?.run_role === 'reviewer' || ['done', 'archived'].includes(storedAs(entry.workflow, from))))
|
|
823
876
|
taken = { ...taken, target: undefined };
|
|
824
877
|
const to = take(db, board, entry, task, from, taken, scope, effects);
|
|
878
|
+
// A kept reviewer is closed by any set on the card, its end or its move to backlog, whatever its status (ADR 0019).
|
|
879
|
+
const after = cardOf(db, id);
|
|
880
|
+
if (name === 'set' || after.backlog || after.archived || ['done', 'cancelled', 'archived'].includes(storedAs(entry.workflow, statusOf(db, after))))
|
|
881
|
+
closeKeptReviewer(db, id, effects);
|
|
825
882
|
settle(db, board, entry, id, effects);
|
|
826
883
|
projectStatus(db, board, entry, id);
|
|
827
|
-
//
|
|
828
|
-
// never turns it into an incident that waits for a second act
|
|
884
|
+
// Explicit recovery or reconfiguration can release a hold, and a run it ends is their decision: the ended-run
|
|
885
|
+
// audit never turns it into an incident that waits for a second act. Resume clears only its pause, through the
|
|
886
|
+
// same declared actions whether the manager or deadline invokes it; independent dispatch holds remain.
|
|
829
887
|
const ended = task.current_run_id && one(db, 'SELECT ended_at FROM task_runs WHERE id = ?', task.current_run_id)?.ended_at != null ? task.current_run_id : null;
|
|
830
888
|
if ((scope.card.dispatch_hold || ended) && roles.some(role => ['manager','creator'].includes(role)) &&
|
|
831
|
-
(['
|
|
889
|
+
(['reclaim','replace_session','reopen','reopen_review','return_review'].includes(name) || (name === 'set' && ['assignee','machine','model'].includes(payload.field))))
|
|
832
890
|
record(db,id,'dispatch_released',{reason:`explicit ${name}`,incident:scope.card.last_events.run_incident?.payload?.run ?? null,...(ended ? { ended_run: ended } : {}),
|
|
833
891
|
...(ended && name === 'set' && payload.field === 'assignee' && (payload.value ?? null) !== (task.assignee ?? null)
|
|
834
892
|
? { reassigned_from: task.assignee ?? null, reassigned_to: payload.value ?? null, abandon_waiting_input: true } : {})});
|
|
@@ -863,7 +921,7 @@ function cardOf(db, id) {
|
|
|
863
921
|
const row = one(db, 'SELECT * FROM tasks WHERE id = ?', id);
|
|
864
922
|
if (!row) throw new Error(`no card ${id}`);
|
|
865
923
|
const ours = one(db, 'SELECT * FROM supercode_cards WHERE task_id = ?', id);
|
|
866
|
-
return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
924
|
+
return { ...row, blocked_by: json(ours?.blocked_by, []) ?? [], paused: json(ours?.paused, null), backlog: json(ours?.backlog, null), detached: json(ours?.detached, null), archived: Boolean(ours?.archived), skills: json(row.skills, []) ?? [], machine: ours?.machine ?? null, subtask_of: ours?.subtask_of ?? null };
|
|
867
925
|
}
|
|
868
926
|
|
|
869
927
|
// one transition: the old status's exit actions, the move, the transition's actions, the new status's entry actions
|
|
@@ -872,7 +930,6 @@ function take(db, board, entry, task, from, transition, scope, effects) {
|
|
|
872
930
|
const target = transition.target ? String(templateValue(transition.target, scope) ?? '') : null;
|
|
873
931
|
if (target && !workflow.statuses[target]) throw new Error(`the workflow moves ${task.id} to ${target}, which it does not declare`);
|
|
874
932
|
const moves = target && target !== from;
|
|
875
|
-
if (moves && scope.card.paused) throw new Error('a paused card must be resumed before its stage can change');
|
|
876
933
|
const ctx = { db, board, entry, id: task.id, scope: { ...scope, before: scope.card }, from, to: target ?? from, effects, runId: task.current_run_id ?? null };
|
|
877
934
|
if (moves) for (const a of workflow.statuses[from].exit ?? []) act(ctx, a);
|
|
878
935
|
if (moves) setStatus(db, workflow, task.id, target);
|
|
@@ -880,16 +937,34 @@ function take(db, board, entry, task, from, transition, scope, effects) {
|
|
|
880
937
|
if (moves) {
|
|
881
938
|
for (const a of workflow.statuses[target].entry ?? []) act(ctx, a);
|
|
882
939
|
if (workflow.statuses[target].run || workflow.statuses[target].run_role) effects.push({ kind: 'run', id: task.id, status: target });
|
|
940
|
+
// a card's final close (done, cancelled, archived) retires the workspace the board made for it, whatever the
|
|
941
|
+
// workflow declares: removed only when that loses nothing, else kept and described on the card (engine cleanupWorkspace)
|
|
942
|
+
if (['done', 'cancelled', 'archived'].includes(storedAs(workflow, target))) queueCleanup(db, task.id);
|
|
883
943
|
}
|
|
884
944
|
return target ?? from;
|
|
885
945
|
}
|
|
886
946
|
|
|
947
|
+
/** Card `id`'s workspace cleanup recorded once for the delivery pass (engine runEffects), which reads its machine's git
|
|
948
|
+
* outside any verb or round and tries again while that machine cannot answer; a card not closed when it runs is left.
|
|
949
|
+
* Its kind is `retire_workspace`, which a reader from before this rule does not know and drops unread: that reader's
|
|
950
|
+
* `cleanup_workspace` forces the worktree's removal, so a rollback leaves the folder rather than losing what is in it. */
|
|
951
|
+
export function queueCleanup(db, id, { scratch = false } = {}) {
|
|
952
|
+
// one row per card: a workflow that declares cleanup_workspace also has its card's board-made scratch folder removed
|
|
953
|
+
// (engine cleanupWorkspace), so its ask turns a pending row's scratch on rather than queueing a second retirement
|
|
954
|
+
const plain = JSON.stringify({ kind: 'retire_workspace', id }), withScratch = JSON.stringify({ kind: 'retire_workspace', id, scratch: true });
|
|
955
|
+
if (one(db, 'SELECT 1 FROM supercode_effects WHERE effect = ? AND failed_at IS NULL', withScratch)) return;
|
|
956
|
+
const pending = one(db, 'SELECT id FROM supercode_effects WHERE effect = ? AND failed_at IS NULL', plain);
|
|
957
|
+
if (pending) { if (scratch) run(db, 'UPDATE supercode_effects SET effect = ? WHERE id = ?', withScratch, pending.id); return; }
|
|
958
|
+
run(db, 'INSERT INTO supercode_effects (effect) VALUES (?)', scratch ? withScratch : plain);
|
|
959
|
+
}
|
|
960
|
+
|
|
887
961
|
/** Put the card in `status`: kanban.db holds its `stored_as`, our table the exact name where they differ. */
|
|
888
962
|
export function setStatus(db, workflow, id, status) {
|
|
889
963
|
const stored = storedAs(workflow, status);
|
|
890
964
|
run(db, 'UPDATE tasks SET status = ? WHERE id = ?', stored, id);
|
|
891
965
|
run(db, `INSERT INTO supercode_cards (task_id, stage, since, after_fired) VALUES (?, ?, ?, NULL)
|
|
892
966
|
ON CONFLICT(task_id) DO UPDATE SET stage = excluded.stage, since = excluded.since, after_fired = NULL`, id, stored === status ? null : status, now());
|
|
967
|
+
record(db, id, 'stage_entered', { status });
|
|
893
968
|
// an arc that ends releases its side sessions with it (side-sessions.mjs)
|
|
894
969
|
if (['done', 'archived'].includes(stored) && !db.prepare('SELECT subtask_of FROM supercode_cards WHERE task_id = ?').get(id)?.subtask_of) releaseAllSideSessions(db, id, `${id} is ${status}`);
|
|
895
970
|
}
|
|
@@ -902,6 +977,11 @@ export function settle(db, board, entry, id, effects = []) {
|
|
|
902
977
|
for (let hops = 0; hops < 16; hops += 1) {
|
|
903
978
|
const task = cardOf(db, id);
|
|
904
979
|
const from = statusOf(db, task);
|
|
980
|
+
// Backlog freezes runnable lifecycle movement, while relations and terminal cleanup still settle. An ended
|
|
981
|
+
// parent may retain its backlog attribution, but its open children must resume/cancel through their workflow.
|
|
982
|
+
if (task.backlog && !['done', 'archived'].includes(storedAs(workflow, from))) {
|
|
983
|
+
projectStatus(db, board, entry, id); return effects;
|
|
984
|
+
}
|
|
905
985
|
const state = workflow.statuses[from];
|
|
906
986
|
// An unchanged card with no automatic/timed moves needs no history scope at all. The organization's five
|
|
907
987
|
// statuses have none, but recomputeReady used to read every history field on every unsettled card anyway.
|
|
@@ -944,6 +1024,42 @@ function dueAfter(db, workflow, task, status, scope) {
|
|
|
944
1024
|
return null;
|
|
945
1025
|
}
|
|
946
1026
|
|
|
1027
|
+
// Expiry is a narrowly delegated ordinary event, not platform authority to clear arbitrary flags.
|
|
1028
|
+
function dueFlagExpiry(task, name, roles, payload, expiry) {
|
|
1029
|
+
const pause = task.paused;
|
|
1030
|
+
return Boolean(expiry?.at === 'card.paused.until' && expiry.event === name && roles.includes('platform') && pause?.generation
|
|
1031
|
+
&& payload.expired_generation === pause.generation && Number.isSafeInteger(pause.until) && pause.until <= now());
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Indexed current deadlines only; called transactionally before admission and idle observation. */
|
|
1035
|
+
export function settleExpiringFlags(db, board, entry, effects = []) {
|
|
1036
|
+
const expiry = entry.workflow.flags?.paused?.expires;
|
|
1037
|
+
if (!expiry) return 0;
|
|
1038
|
+
if (expiry.at !== 'card.paused.until' || !expiry.event) throw new Error('paused.expires must name card.paused.until and its ordinary resume event');
|
|
1039
|
+
let count = 0;
|
|
1040
|
+
for (;;) {
|
|
1041
|
+
const due = all(db, `SELECT task_id,paused FROM supercode_cards WHERE paused IS NOT NULL AND ${PAUSE_DEADLINE} <= ?
|
|
1042
|
+
AND json_type(paused,'$.until')='integer' AND length(json_extract(paused,'$.generation'))>0 ORDER BY ${PAUSE_DEADLINE},task_id LIMIT 64`, now());
|
|
1043
|
+
if (!due.length) break;
|
|
1044
|
+
for (const row of due) {
|
|
1045
|
+
const pause = json(row.paused, null);
|
|
1046
|
+
const result = fire(db, board, entry, row.task_id, expiry.event, { roles: ['platform'], payload: {
|
|
1047
|
+
expired_generation: pause.generation, reason: `pause expired at ${new Date(pause.until * 1000).toISOString()}`,
|
|
1048
|
+
} });
|
|
1049
|
+
if (cardOf(db, row.task_id).paused) throw new Error(`${row.task_id}: declared pause expiry did not clear its flag`);
|
|
1050
|
+
effects.push(...result.effects); count++;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return count;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** The next current deadline drives the owner's wake even when launches are disabled. */
|
|
1057
|
+
export function nextFlagExpiry(db, entry) {
|
|
1058
|
+
if (!entry.workflow.flags?.paused?.expires) return null;
|
|
1059
|
+
return one(db, `SELECT ${PAUSE_DEADLINE} AS at FROM supercode_cards WHERE paused IS NOT NULL AND ${PAUSE_DEADLINE} IS NOT NULL
|
|
1060
|
+
AND json_type(paused,'$.until')='integer' AND length(json_extract(paused,'$.generation'))>0 ORDER BY ${PAUSE_DEADLINE},task_id LIMIT 1`)?.at ?? null;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
947
1063
|
/**
|
|
948
1064
|
* Where a new card starts: the first `initial` transition that holds (`event` is the create's own words), its status
|
|
949
1065
|
* set and its actions run (a goal card by the workflow's default, for one). Answers the status. A validator that fails
|
|
@@ -982,6 +1098,11 @@ function act(ctx, action) {
|
|
|
982
1098
|
// what happens is the workflow's. A kind with no entry here carries the event's own fields.
|
|
983
1099
|
const firstLine = (text) => String(text ?? '').trim().split('\n')[0].slice(0, 400) || null;
|
|
984
1100
|
const FORMAT = {
|
|
1101
|
+
paused: ({ e, after }) => ({ reason: after.paused?.reason ?? e.reason, until: after.paused?.until, generation: after.paused?.generation, by: after.paused?.by }),
|
|
1102
|
+
pause_extended: ({ before, after }) => ({ ...after.paused, previous: before.paused }),
|
|
1103
|
+
resumed: ({ e, before }) => ({ reason: e.reason ?? null, pause: before.paused, cause: e.expired_generation ? 'expired' : 'manager', ...(e.expired_generation ? { expired_generation: e.expired_generation } : {}) }),
|
|
1104
|
+
backlogged: ({ after, before }) => ({ ...after.backlog, previous_lane: before.status }),
|
|
1105
|
+
planned: ({ e, before }) => ({ reason: e.reason ?? null, backlog: before.backlog }),
|
|
985
1106
|
completed: ({ e }) => {
|
|
986
1107
|
const artifacts = Array.isArray(e.metadata?.artifacts) ? e.metadata.artifacts.filter((a) => typeof a === 'string' && a.trim()) : [];
|
|
987
1108
|
return { ci: e.ci ?? null, result_len: e.result ? String(e.result).length : 0, summary: firstLine(e.summary ?? e.result), ...(artifacts.length ? { artifacts } : {}) };
|
|
@@ -990,15 +1111,17 @@ const FORMAT = {
|
|
|
990
1111
|
block_loop_detected: ({ e, after, source, params }) => ({ reason: e.reason ?? null, kind: e.kind ?? null, recurrences: Number(after.block_recurrences || 0), limit: params.block_recurrence_limit, source_status: source }),
|
|
991
1112
|
dependency_wait: ({ e, source }) => ({ reason: e.reason ?? null, on: e.on ?? null, source_status: source }),
|
|
992
1113
|
review_requested: ({ e, before, after }) => ({ summary: firstLine(e.summary), ci: e.ci ?? null, pr: e.ci?.pr ?? null, commit: e.ci?.commit ?? null,
|
|
1114
|
+
requested_pr: e.pr ?? null, requested_pr_head: e.pr_head ?? null,
|
|
993
1115
|
implementer: before.assignee, implementer_machine: before.machine ?? null, implementer_model: before.implementer_model ?? null,
|
|
994
1116
|
implementer_model_override: before.model_override ?? null, implementer_provider_override: before.provider_override ?? null, reviewer: after.assignee }),
|
|
995
1117
|
// the reviewer is the lane that reviewed (the card's assignee while in review), unless the verdict names one
|
|
996
1118
|
changes_requested: ({ e, before, after }) => ({ reason: e.reason ?? null, reviewer: e.reviewer ?? before.assignee ?? null, implementer: after.assignee }),
|
|
997
1119
|
review_reopened: ({ after, to }) => ({ status: to, implementer: after.assignee }),
|
|
998
1120
|
// the manager's return of a review with no verdict: the request it returned and its run, so the next review's selection
|
|
999
|
-
// reads which heads it named (ci.mjs returnedReview)
|
|
1121
|
+
// reads which heads it named (ci.mjs returnedReview); a reopen-review return names the re-declaration it answered
|
|
1122
|
+
// (`redeclared`), and holds none
|
|
1000
1123
|
review_returned: ({ e, before, after, to }) => ({ reason: e.reason ?? null, status: to, review_request: before.last_events.review_requested?.id ?? null,
|
|
1001
|
-
review_run: before.current_run_id ?? null, implementer: after.assignee }),
|
|
1124
|
+
review_run: before.current_run_id ?? null, implementer: after.assignee, ...(e.redeclared ? { redeclared: e.redeclared } : {}) }),
|
|
1002
1125
|
review_voided: ({ e, before }) => ({ reason: e.reason, review_request: before.last_events.review_requested?.id ?? null,
|
|
1003
1126
|
review_run: before.latest_review?.id ?? null, verdict: Math.max(before.last_events.review_pass?.id ?? 0, before.last_events.changes_requested?.id ?? 0) || null }),
|
|
1004
1127
|
// what arrived (an unblock says it), and where the card resumes
|
|
@@ -1027,14 +1150,14 @@ const ACTIONS = {
|
|
|
1027
1150
|
let blocks = json(row?.blocked_by, []) ?? [];
|
|
1028
1151
|
if (add) {
|
|
1029
1152
|
const id = String(add);
|
|
1030
|
-
if (id === ctx.id) throw
|
|
1153
|
+
if (id === ctx.id) throw requestDetail('a card cannot block itself');
|
|
1031
1154
|
const kind = id.startsWith('m-') ? 'message' : 'card';
|
|
1032
|
-
if (kind === 'card' && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', id)) throw
|
|
1155
|
+
if (kind === 'card' && !one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', id)) throw requestDetail(`no blocking card ${id}`);
|
|
1033
1156
|
if (kind === 'card') {
|
|
1034
1157
|
const seen = new Set(); const pending = [id];
|
|
1035
1158
|
while (pending.length) {
|
|
1036
1159
|
const node = pending.pop();
|
|
1037
|
-
if (node === ctx.id) throw
|
|
1160
|
+
if (node === ctx.id) throw requestDetail('blocking relation would create a cycle');
|
|
1038
1161
|
if (seen.has(node)) continue;
|
|
1039
1162
|
seen.add(node);
|
|
1040
1163
|
const related = json(one(ctx.db, 'SELECT blocked_by FROM supercode_cards WHERE task_id = ?', node)?.blocked_by, []) ?? [];
|
|
@@ -1044,12 +1167,12 @@ const ACTIONS = {
|
|
|
1044
1167
|
if (!blocks.some((b) => b.id === id)) blocks.push({ id, kind, reason, by: actorOf(), since: now() });
|
|
1045
1168
|
}
|
|
1046
1169
|
if (remove) {
|
|
1047
|
-
if (!String(reason ?? '').trim()) throw
|
|
1170
|
+
if (!String(reason ?? '').trim()) throw requestDetail('unblock must say what arrived');
|
|
1048
1171
|
let resolved = remove;
|
|
1049
1172
|
if (remove !== '*' && remove !== 'messages') {
|
|
1050
1173
|
const matches = blocks.filter(b => b.id === remove || (b.kind === 'message' && String(remove).startsWith('m-') &&
|
|
1051
1174
|
(String(remove).startsWith(b.id) || b.id.startsWith(String(remove)))));
|
|
1052
|
-
if (matches.length !== 1) throw
|
|
1175
|
+
if (matches.length !== 1) throw requestDetail(matches.length ? `ambiguous dependency ${remove}; name one of ${matches.map(b => b.id).join(', ')}` : `no dependency ${remove}; current blockers: ${blocks.map(b => b.id).join(', ') || 'none'}`);
|
|
1053
1176
|
resolved = matches[0].id;
|
|
1054
1177
|
}
|
|
1055
1178
|
blocks = blocks.filter((b) => resolved === 'messages' ? b.kind !== 'message' : resolved !== '*' && b.id !== resolved);
|
|
@@ -1059,14 +1182,27 @@ const ACTIONS = {
|
|
|
1059
1182
|
run(ctx.db, 'DELETE FROM task_links WHERE child_id = ?', ctx.id);
|
|
1060
1183
|
for (const block of blocks.filter((b) => b.kind === 'card')) run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', block.id, ctx.id);
|
|
1061
1184
|
},
|
|
1062
|
-
flag(ctx, { name, value, reason = null }) {
|
|
1063
|
-
if (!['paused', 'archived'].includes(name)) throw
|
|
1185
|
+
flag(ctx, { name, value, reason = null, until = null }) {
|
|
1186
|
+
if (!['paused', 'backlog', 'archived'].includes(name)) throw requestDetail(`unknown flag ${name}`);
|
|
1064
1187
|
if (name === 'paused') {
|
|
1065
1188
|
const was = json(one(ctx.db, 'SELECT paused FROM supercode_cards WHERE task_id = ?', ctx.id)?.paused, null);
|
|
1066
1189
|
const clearBy = ctx.entry.workflow.flags?.paused?.clear_by ?? ['pauser', 'owner'];
|
|
1067
|
-
|
|
1068
|
-
if (value && !
|
|
1069
|
-
|
|
1190
|
+
const expiry = dueFlagExpiry({ paused: was }, ctx.scope.verb, ctx.scope.sender ?? [], ctx.scope.event ?? {}, ctx.entry.workflow.flags?.paused?.expires);
|
|
1191
|
+
if (!value && was && !expiry && !clearBy.some((role) => role === 'pauser' ? was.by === actorOf() : ctx.scope.sender?.includes(role))) throw ownerRule(`only ${clearBy.join(' or ')} may resume this card; ask the manager with your reason`);
|
|
1192
|
+
if (value && !ctx.scope.sender?.includes('manager')) throw ownerRule('only the manager pauses a card');
|
|
1193
|
+
if (value && (typeof reason !== 'string' || !reason.trim())) throw requestDetail('pause needs a reason');
|
|
1194
|
+
if (value) assertRestingWrite(name, { until: until ?? ctx.scope.event?.until }, was);
|
|
1195
|
+
cardField(ctx, name, value ? JSON.stringify(RESTING_WRITE_MODE === 'legacy-holds' ? { by: actorOf(), reason }
|
|
1196
|
+
: { by: actorOf(), reason, until: deadlineUntil(until ?? ctx.scope.event?.until, now(), 'pause'), generation: randomBytes(16).toString('hex') }) : null);
|
|
1197
|
+
} else if (name === 'backlog') {
|
|
1198
|
+
if (value && !String(reason ?? '').trim()) throw requestDetail('backlog needs a reason');
|
|
1199
|
+
if (value) assertRestingWrite(name, {});
|
|
1200
|
+
const backlog = value ? { by: actorOf(), reason, since: now() } : null;
|
|
1201
|
+
if (value) assertBacklogQualified(ctx.db, ctx.id, { backlog });
|
|
1202
|
+
cardField(ctx, name, backlog ? JSON.stringify(backlog) : null);
|
|
1203
|
+
if (value && ctx.scope.before.paused) ctx.effects.push(...fire(ctx.db, ctx.board, ctx.entry, ctx.id, 'resume', {
|
|
1204
|
+
roles: ['manager'], payload: { reason: 'manager migration from parked pause to backlog' },
|
|
1205
|
+
}).effects);
|
|
1070
1206
|
} else cardField(ctx, name, Number(Boolean(value)));
|
|
1071
1207
|
},
|
|
1072
1208
|
every(ctx, interval) {
|
|
@@ -1105,15 +1241,15 @@ const ACTIONS = {
|
|
|
1105
1241
|
},
|
|
1106
1242
|
assign(ctx, { field, value }) {
|
|
1107
1243
|
if (field === 'reviewer') {
|
|
1108
|
-
if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw
|
|
1244
|
+
if (value != null && (typeof value !== 'string' || !ctx.entry.profiles?.[value]?.worker?.harness)) throw requestDetail('A card reviewer names a runnable profile in this home.');
|
|
1109
1245
|
record(ctx.db, ctx.id, 'reviewer_set', { reviewer: value ?? null }, ctx.runId);
|
|
1110
1246
|
return;
|
|
1111
1247
|
}
|
|
1112
1248
|
if (field === 'machine') {
|
|
1113
1249
|
const task = one(ctx.db, 'SELECT t.current_run_id,t.workspace_kind,c.workspace_anchor,c.machine FROM tasks t JOIN supercode_cards c ON c.task_id=t.id WHERE t.id=?', ctx.id);
|
|
1114
|
-
if (task.current_run_id) throw
|
|
1250
|
+
if (task.current_run_id) throw requestDetail('End the current run before moving its card.');
|
|
1115
1251
|
if ((value ?? null) === task.machine) return;
|
|
1116
|
-
if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw
|
|
1252
|
+
if (task.workspace_kind !== 'scratch' && (!task.workspace_anchor || isWorkspacePath(task.workspace_anchor))) throw requestDetail('declare the repository and branch with workflow specify --workspace before moving a legacy path card');
|
|
1117
1253
|
ACTIONS.forget_session(ctx, '*');
|
|
1118
1254
|
// a worktree the card made with only its path recorded (workspace.mjs unrecordedMaking) is recorded as made before
|
|
1119
1255
|
// the move clears the path, so the branch it made stays its own on the new machine
|
|
@@ -1124,7 +1260,7 @@ const ACTIONS = {
|
|
|
1124
1260
|
cardField(ctx, 'machine', value ?? null);
|
|
1125
1261
|
return;
|
|
1126
1262
|
}
|
|
1127
|
-
if (!ASSIGNABLE.has(field)) throw
|
|
1263
|
+
if (!ASSIGNABLE.has(field)) throw requestDetail(`the workflow assigns ${field}, which is not a card field it may set`);
|
|
1128
1264
|
if (field === 'assignee') {
|
|
1129
1265
|
// A model override names a model of the harness it was chosen for; a lane of another harness cannot run it
|
|
1130
1266
|
// (a Claude model left on a Codex lane fails every turn), so it goes with the old lane, on the record
|
|
@@ -1169,6 +1305,8 @@ const ACTIONS = {
|
|
|
1169
1305
|
// the card's own coder bound as its reviewer leaves the reviewer slot unclosed (handBackCoder), here or earlier in
|
|
1170
1306
|
// this move (its close_session); the run being ended is the one it was handed back from
|
|
1171
1307
|
const handed = handBackCoder(ctx.db, ctx.id, runId) || handedBackFrom(json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode, runId);
|
|
1308
|
+
// the arc's reviewer keeps its session while a review is pending: the next review is its next round's brief
|
|
1309
|
+
if (close_session && reviewerKept(ctx, runId)) close_session = false;
|
|
1172
1310
|
if (!handed) runSession(ctx, session => ({ ...session, retain_after_run: !close_session }));
|
|
1173
1311
|
// A declared completion may end the attempt while retaining its role session. In particular, its own caller
|
|
1174
1312
|
// must be able to finish and return before the board starts the independent review.
|
|
@@ -1186,13 +1324,14 @@ const ACTIONS = {
|
|
|
1186
1324
|
run(ctx.db, 'UPDATE tasks SET current_run_id = NULL, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ?', ctx.id);
|
|
1187
1325
|
},
|
|
1188
1326
|
claim(ctx, role, scope) {
|
|
1189
|
-
if (detachedCard(ctx.db, ctx.id)) throw
|
|
1327
|
+
if (detachedCard(ctx.db, ctx.id)) throw requestDetail('a detached card must receive a verified adoption before starting another run');
|
|
1190
1328
|
const task = cardOf(ctx.db, ctx.id);
|
|
1329
|
+
if (task.backlog) throw requestDetail('a backlog card cannot claim a run; the manager must plan it first');
|
|
1191
1330
|
const t = now();
|
|
1192
1331
|
const ttl = Number(ctx.entry.params.claim_ttl_seconds) || 900;
|
|
1193
|
-
if (ctx.entry.workflow.run && (task.subtask_of || scope.card.paused || scope.card.archived || scope.card.blocked_by.length)) throw
|
|
1332
|
+
if (ctx.entry.workflow.run && (task.subtask_of || scope.card.paused || scope.card.archived || scope.card.blocked_by.length)) throw requestDetail('this card cannot run while blocked, paused, archived, or a subtask');
|
|
1194
1333
|
const lock = ctx.scope.event?.lock;
|
|
1195
|
-
if (!lock) throw
|
|
1334
|
+
if (!lock) throw requestDetail('a claim needs the dispatcher\'s lock');
|
|
1196
1335
|
if (task.current_run_id) {
|
|
1197
1336
|
run(ctx.db, "UPDATE task_runs SET status = 'reclaimed', outcome = 'reclaimed', summary = COALESCE(summary, 'invariant recovery on re-claim'), ended_at = ?, claim_lock = NULL, claim_expires = NULL, worker_pid = NULL WHERE id = ? AND ended_at IS NULL", t, task.current_run_id);
|
|
1198
1337
|
}
|
|
@@ -1227,7 +1366,7 @@ const ACTIONS = {
|
|
|
1227
1366
|
given ? JSON.stringify({ ...was, ...given, ...(was.supercode ? { supercode: was.supercode } : {}) }) : (Object.keys(was).length ? JSON.stringify(was) : null), ctx.runId);
|
|
1228
1367
|
},
|
|
1229
1368
|
link(ctx, parent) {
|
|
1230
|
-
if (!one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', parent)) throw
|
|
1369
|
+
if (!one(ctx.db, 'SELECT id FROM tasks WHERE id = ?', parent)) throw requestDetail(`${parent} is no card on this board`);
|
|
1231
1370
|
run(ctx.db, 'INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)', parent, ctx.id);
|
|
1232
1371
|
},
|
|
1233
1372
|
unlink(ctx, parent) {
|
|
@@ -1236,13 +1375,14 @@ const ACTIONS = {
|
|
|
1236
1375
|
specify(ctx, said) {
|
|
1237
1376
|
if (said?.workspace) {
|
|
1238
1377
|
const task = cardOf(ctx.db, ctx.id), ws = said.workspace;
|
|
1239
|
-
if (task.current_run_id) throw
|
|
1240
|
-
const moves = Object.hasOwn(ws, 'machine');
|
|
1241
|
-
// A retarget
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1378
|
+
if (task.current_run_id) throw requestDetail('end the current run before changing its workspace');
|
|
1379
|
+
const moves = Object.hasOwn(ws, 'machine') && (ws.machine ?? null) !== (task.machine ?? null);
|
|
1380
|
+
// A same-machine retarget changes work, not conversation identity. Only an actual admitted machine move
|
|
1381
|
+
// authorizes replacement; stopped coder and reviewer conversations are retained too (ADR 0248).
|
|
1382
|
+
if (moves) {
|
|
1383
|
+
if (ws.kind !== 'scratch' && (!ws.repository || isWorkspacePath(ws.repository))) throw requestDetail('a machine move requires a portable repository declaration');
|
|
1384
|
+
forgetSessions(ctx, '*', null);
|
|
1385
|
+
}
|
|
1246
1386
|
const prior = one(ctx.db, 'SELECT workspace_anchor FROM supercode_cards WHERE task_id=?', ctx.id);
|
|
1247
1387
|
record(ctx.db, ctx.id, 'workspace_changed', { from: { kind: task.workspace_kind, repository: prior?.workspace_anchor, branch: task.branch_name, ...(moves ? { machine: task.machine } : {}) }, to: { kind: ws.kind, repository: ws.repository, branch: ws.branch, ...(moves ? { machine: ws.machine } : {}) } }, null);
|
|
1248
1388
|
run(ctx.db, 'UPDATE tasks SET workspace_kind=?,workspace_path=?,branch_name=? WHERE id=?', ws.kind, ws.path ?? null, ws.branch ?? null, ctx.id);
|
|
@@ -1293,7 +1433,7 @@ const ACTIONS = {
|
|
|
1293
1433
|
comment(ctx, body) {
|
|
1294
1434
|
run(ctx.db, 'INSERT INTO task_comments (task_id, author, body, created_at) VALUES (?, ?, ?, ?)', ctx.id, 'dispatcher', String(body), now());
|
|
1295
1435
|
},
|
|
1296
|
-
cleanup_workspace(ctx) { ctx.
|
|
1436
|
+
cleanup_workspace(ctx) { queueCleanup(ctx.db, ctx.id, { scratch: true }); },
|
|
1297
1437
|
stop_run(ctx) { ctx.effects.push({ kind: 'stop_run', id: ctx.id, runId: ctx.runId }); },
|
|
1298
1438
|
// A review's pass is the work locked in: the dispatcher merges every PR the verdict judged, at the head it judged
|
|
1299
1439
|
// (dispatch.mjs mergeReviewed), and sends the card `reviewed_merged` once all are, or `merge_refused` when a head moved.
|
|
@@ -1306,6 +1446,7 @@ const ACTIONS = {
|
|
|
1306
1446
|
// the run leaving the status is the one whose session closes, never a run started after it (a reclaim's successor)
|
|
1307
1447
|
close_session(ctx, slot) {
|
|
1308
1448
|
const through = ctx.runId ?? one(ctx.db, 'SELECT MAX(id) AS id FROM task_runs WHERE task_id = ?', ctx.id)?.id ?? 0;
|
|
1449
|
+
if (slot === 'reviewer' && reviewerKept(ctx)) return;
|
|
1309
1450
|
requestSessionClose(ctx.db, ctx.id, { slot, through });
|
|
1310
1451
|
ctx.effects.push({ kind: 'close_session', id: ctx.id, slot, runId: through });
|
|
1311
1452
|
},
|
|
@@ -1332,7 +1473,7 @@ const ACTIONS = {
|
|
|
1332
1473
|
// a question from the board to a session, sent under a message id chosen now: `{{asked}}` in the actions after it is
|
|
1333
1474
|
// that id (what a block waits on), and the answer to it lands in the board's mailbox
|
|
1334
1475
|
ask(ctx, { to, text }) {
|
|
1335
|
-
if (!to) throw
|
|
1476
|
+
if (!to) throw requestDetail(`the workflow asks nobody about ${ctx.id} (ask.to is empty)`);
|
|
1336
1477
|
const id = `m-${randomBytes(12).toString('hex')}`;
|
|
1337
1478
|
ctx.scope.asked = id;
|
|
1338
1479
|
const batch = ctx.scope.verb === 'request_state' ? {
|
|
@@ -1395,13 +1536,40 @@ export function addDependency(db, entry, id, parent) {
|
|
|
1395
1536
|
}
|
|
1396
1537
|
|
|
1397
1538
|
/** Persist close intent in the same transaction as the transition; I/O can fail or never run. */
|
|
1539
|
+
/** The verdicts that keep the arc's reviewer: its own changes requested, pass or complete (ADR 0019). */
|
|
1540
|
+
const VERDICT_VERBS = new Set(['request_changes', 'request_state', 'complete']);
|
|
1541
|
+
/** Whether the arc's reviewer keeps its session as its run ends (`runId`, else its reviewer slot): only its own verdict,
|
|
1542
|
+
* while a review is pending, so the next one is delivered to that same session as its round's brief (ADR 0019).
|
|
1543
|
+
* Every other end of its run closes it. */
|
|
1544
|
+
function reviewerKept(ctx, runId = null) {
|
|
1545
|
+
if (!VERDICT_VERBS.has(ctx.scope?.verb) || !ctx.scope?.sender?.includes('reviewer')) return false;
|
|
1546
|
+
if (runId != null) {
|
|
1547
|
+
const session = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id = ?', runId)?.metadata, {})?.supercode;
|
|
1548
|
+
if (session?.role !== 'reviewer' && session?.slot !== 'reviewer') return false;
|
|
1549
|
+
}
|
|
1550
|
+
return pendingReviewRequests(ctx.db, ctx.id).length > 0;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/** Close the arc's kept reviewer (ADR 0019): the latest reviewer-slot run whose session its verdict kept open. */
|
|
1554
|
+
export function closeKeptReviewer(db, id, effects = null) {
|
|
1555
|
+
for (const row of all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND ended_at IS NOT NULL ORDER BY id DESC', id)) {
|
|
1556
|
+
const session = json(row.metadata, {})?.supercode;
|
|
1557
|
+
if ((session?.slot ?? 'worker') !== 'reviewer') continue;
|
|
1558
|
+
if (session.retain_after_run && !session.close_requested && !session.close_confirmed_at && !session.forgotten && !session.close_transferred_to) {
|
|
1559
|
+
requestSessionClose(db, id, { runId: row.id });
|
|
1560
|
+
effects?.push({ kind: 'close_session', id, slot: 'reviewer', runId: row.id });
|
|
1561
|
+
}
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1398
1566
|
export function requestSessionClose(db, id, { runId = null, slot = null, through = null } = {}) {
|
|
1399
1567
|
for (const row of all(db, 'SELECT id, metadata FROM task_runs WHERE task_id = ?', id)) {
|
|
1400
1568
|
if ((runId != null && row.id !== runId) || (through != null && row.id > through)) continue;
|
|
1401
1569
|
const whole = json(row.metadata, {}) ?? {};
|
|
1402
1570
|
const session = whole.supercode;
|
|
1403
1571
|
if (detachedSession(db, session)) continue;
|
|
1404
|
-
if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
|
|
1572
|
+
if ((!session?.pane && !session?.launch_key && !(session?.surface === 'native' && session.session_id) && !(session?.surface === 'headless' && session.pid)) || session.close_transferred_to || session.close_confirmed_at || (slot != null && (session.slot ?? 'worker') !== slot)) continue;
|
|
1405
1573
|
// A reviewer binding of the card's own coder is never the review's to close (handBackCoder)
|
|
1406
1574
|
if ((session.slot ?? 'worker') === 'reviewer' && handBackCoder(db, id, row.id)) continue;
|
|
1407
1575
|
if (session.close_requested && !session.closed_at) continue;
|
|
@@ -1413,15 +1581,38 @@ export function requestSessionClose(db, id, { runId = null, slot = null, through
|
|
|
1413
1581
|
|
|
1414
1582
|
/**
|
|
1415
1583
|
* Let go of card `ctx.id`'s sessions in slot `name` ('*': every slot), those of another harness than `harness` when it is
|
|
1416
|
-
* given
|
|
1417
|
-
* session that stays the arc's (openCoder): it is neither closed nor forgotten, so its next run is delivered into it.
|
|
1584
|
+
* given. Only the retained deliberate configuration/replacement decision permits its successor to launch.
|
|
1418
1585
|
*/
|
|
1419
|
-
function forgetSessions(ctx, name, harness
|
|
1586
|
+
function forgetSessions(ctx, name, harness) {
|
|
1587
|
+
const verb = ctx.scope.verb, said = ctx.scope.event ?? {};
|
|
1588
|
+
const manager = ctx.scope.sender?.some(role => ['manager', 'owner'].includes(role));
|
|
1589
|
+
const machineChange = verb === 'set' && said.field === 'machine' && (said.value ?? null) !== (ctx.scope.before.machine ?? null);
|
|
1590
|
+
const workspaceMove = verb === 'specify' && Object.hasOwn(said.workspace ?? {}, 'machine')
|
|
1591
|
+
&& (said.workspace.machine ?? null) !== (ctx.scope.before.machine ?? null);
|
|
1592
|
+
const harnessChange = verb === 'set' && said.field === 'assignee' && harness
|
|
1593
|
+
&& ctx.entry.profiles?.[ctx.scope.before.assignee]?.worker?.harness !== harness;
|
|
1594
|
+
const replacement = verb === 'replace_session';
|
|
1595
|
+
if (!machineChange && !workspaceMove && !harnessChange && !replacement) return;
|
|
1596
|
+
if (!manager) throw new Error('only a deliberate manager or owner decision may replace an established role conversation');
|
|
1597
|
+
const targetMachine = workspaceMove ? said.workspace.machine : machineChange ? said.value : ctx.scope.before.machine;
|
|
1598
|
+
// A replacement's successor runs on the profile the card now assigns (in review, its reviewer), never the old session's
|
|
1599
|
+
// harness: a slot whose profile moved to another harness could otherwise never be authorized.
|
|
1600
|
+
const assigned = replacement ? ctx.entry.profiles?.[ctx.scope.before.assignee]?.worker?.harness : null;
|
|
1601
|
+
ctx.replacementSource ??= { id: randomBytes(16).toString('hex'), kind: verb, field: said.field ?? null,
|
|
1602
|
+
value: said.value ?? null, ...(workspaceMove ? { workspace: said.workspace } : {}) };
|
|
1603
|
+
ctx.replacementSlots ??= new Map();
|
|
1420
1604
|
for (const row of all(ctx.db, 'SELECT id, metadata FROM task_runs WHERE task_id = ? AND metadata IS NOT NULL ORDER BY id DESC', ctx.id)) {
|
|
1421
1605
|
const whole = json(row.metadata, {}) ?? {};
|
|
1422
1606
|
const session = whole.supercode;
|
|
1423
|
-
if ((!session?.session_id && !session?.pane && !session?.launch_key) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
|
|
1424
|
-
|
|
1607
|
+
if ((!session?.session_id && !session?.pane && !session?.launch_key && !session?.pid) || (name !== '*' && (session.slot ?? 'worker') !== name) || (harness && (session.harness ?? 'claude-code') === harness)) continue;
|
|
1608
|
+
const slot = session.slot ?? 'worker';
|
|
1609
|
+
// An agent that ended here is never named as the successor: the decision names
|
|
1610
|
+
// no agent, and the role's next generation is minted at launch (ADR 0248: an ended identity is not revived).
|
|
1611
|
+
const ended = session.agent_id && session.agent_org && endedHere(ctx.board.root, session.agent_org, session.agent_id);
|
|
1612
|
+
if (!ctx.replacementSlots.has(slot)) ctx.replacementSlots.set(slot, authorizeReplacement(ctx.db, ctx.id, slot, {
|
|
1613
|
+
source: ctx.replacementSource, reason: said.reason ?? `deliberate ${verb}`,
|
|
1614
|
+
target: { harness: harness ?? assigned ?? session.harness ?? 'claude-code', machine: targetMachine ?? null, agent: ended ? null : session.agent_id ?? null },
|
|
1615
|
+
}));
|
|
1425
1616
|
requestSessionClose(ctx.db, ctx.id, { runId: row.id });
|
|
1426
1617
|
const latest = json(one(ctx.db, 'SELECT metadata FROM task_runs WHERE id=?', row.id)?.metadata, {}) ?? {};
|
|
1427
1618
|
// a reviewer binding the close handed back to its coder's slot is the coder's, and is not forgotten with the review
|
|
@@ -1551,7 +1742,12 @@ export function runEvent(db, id, kind, payload) {
|
|
|
1551
1742
|
export function fireRun(db, board, entry, id, name, { roles = [], payload = {}, extra = {} } = {}) {
|
|
1552
1743
|
const task = cardOf(db, id);
|
|
1553
1744
|
const row = task.current_run_id && one(db, 'SELECT * FROM task_runs WHERE id = ?', task.current_run_id);
|
|
1554
|
-
if (!row
|
|
1745
|
+
if (!row && name === 'heartbeat') {
|
|
1746
|
+
record(db, id, 'heartbeat', payload, null);
|
|
1747
|
+
const status = statusOf(db, task);
|
|
1748
|
+
return { from: status, to: status, moved: false, effects: [] };
|
|
1749
|
+
}
|
|
1750
|
+
if (!row) throw requestDetail(`${id} has no current run for ${name}`);
|
|
1555
1751
|
const machine = entry.workflow.run;
|
|
1556
1752
|
const meta = json(row.metadata, {}) ?? {};
|
|
1557
1753
|
const from = meta.supercode?.state ?? machine.initial;
|
|
@@ -1559,7 +1755,7 @@ export function fireRun(db, board, entry, id, name, { roles = [], payload = {},
|
|
|
1559
1755
|
const candidates = [...(machine.states[from]?.on?.[name] ?? []), ...(machine.on?.[name] ?? [])];
|
|
1560
1756
|
const t = candidates.find((t) => (!t.from?.length || t.from.includes(from)) && (!t.by?.length || t.by.some((r) => roles.includes(r))) && holds(t.guard ?? null, scope));
|
|
1561
1757
|
if (!t) { const error = new Error(`${id}: run event ${name} does not apply`); error.noTransition = true; throw error; }
|
|
1562
|
-
for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw
|
|
1758
|
+
for (const v of t.validate ?? []) if (!holds(v.expr, scope)) throw requestDetail(render(v.message, scope));
|
|
1563
1759
|
const to = t.target ? String(templateValue(t.target, scope)) : from;
|
|
1564
1760
|
if (!machine.states[to]) throw new Error(`unknown run state ${to}`);
|
|
1565
1761
|
const effects = [];
|
|
@@ -1624,10 +1820,16 @@ function settleRelations(db, board, entry, id, effects) {
|
|
|
1624
1820
|
export function storedStatus(db, board, entry, id) {
|
|
1625
1821
|
const task = cardOf(db, id);
|
|
1626
1822
|
const status = statusOf(db, task);
|
|
1823
|
+
// An ended card stores its end: a hold left on it (a block, a pause, a backlog) never outranks done or cancelled.
|
|
1824
|
+
const ended = ['done', 'archived', 'cancelled'].includes(storedAs(entry.workflow, status));
|
|
1825
|
+
if (task.backlog && !task.archived && !ended) {
|
|
1826
|
+
assertBacklogQualified(db, id, { backlog: task.backlog });
|
|
1827
|
+
return { status, stored: 'todo' };
|
|
1828
|
+
}
|
|
1627
1829
|
// Native holds are stored honestly. Hermes lowering is an export concern;
|
|
1628
1830
|
// older homes may still declare a lossy paused -> blocked storage rule.
|
|
1629
1831
|
const nativeHolds = Boolean(entry.workflow.flags?.paused);
|
|
1630
|
-
if (nativeHolds && !task.archived) {
|
|
1832
|
+
if (nativeHolds && !task.archived && !ended) {
|
|
1631
1833
|
if (task.paused) return { status, stored: 'paused' };
|
|
1632
1834
|
if (task.blocked_by.length) return { status, stored: 'blocked' };
|
|
1633
1835
|
}
|
|
@@ -1639,7 +1841,7 @@ export function storedStatus(db, board, entry, id) {
|
|
|
1639
1841
|
}
|
|
1640
1842
|
|
|
1641
1843
|
export function projectStatus(db, board, entry, id) {
|
|
1642
|
-
if (!entry.workflow.stored_as?.length && !entry.workflow.flags?.paused) return;
|
|
1844
|
+
if (!entry.workflow.stored_as?.length && !entry.workflow.flags?.paused && !entry.workflow.flags?.backlog) return;
|
|
1643
1845
|
const { status, stored } = storedStatus(db, board, entry, id);
|
|
1644
1846
|
// Only a change is written: an unchanged write still commits, and a commit wakes the dispatcher that called this.
|
|
1645
1847
|
run(db, 'UPDATE tasks SET status = ? WHERE id = ? AND status IS NOT ?', stored, id, stored);
|