@volter/supercode-orchestrator 0.5.219 → 0.5.221
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/board/dispatch.mjs +56 -5
- package/board/listing.mjs +8 -3
- package/board/workflow.mjs +64 -9
- package/package.json +2 -2
package/board/dispatch.mjs
CHANGED
|
@@ -52,11 +52,11 @@ import { basename, dirname, join } from 'node:path';
|
|
|
52
52
|
import { createHash, randomUUID } from 'node:crypto';
|
|
53
53
|
import { fileURLToPath } from 'node:url';
|
|
54
54
|
import { view, boardCopyOf, boardKey, boardPath, cardHandleOf, readableIdOf, briefPath, claimer, claimHolds, copyRefusal, event, json, listBoards, logsDir, newClaim, now, NOTICE_RUN_IDS, openBoard, openBoardSnapshot, pendingLaunches, sessionBound, setActor, storageFault, tx } from './store.mjs';
|
|
55
|
-
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
55
|
+
import { doors, fire, loadWorkflow, resumeOf, runSpec, runWorkingSeconds, requestSessionClose, retireSupersededRuns, refreshCardInputs, refreshRelationAnswers, unrelayedStateAnswers, transitionsFor, scopeOf, statusOf, projectStatus, storedStatus, templateValue, workflowFor } from './workflow.mjs';
|
|
56
56
|
import { checkWorkspace, assertWorkspaceOwner, notePresence, resolveWorkspace } from './workspace.mjs';
|
|
57
57
|
import { capacity, dispatched, executionHost, harnessDefaults, holdings } from './capacity.mjs';
|
|
58
58
|
import { evaluate, holds, render } from './expr.mjs';
|
|
59
|
-
import { identifyNotice, numbered } from './notices.mjs';
|
|
59
|
+
import { cardNumber, identifyNotice, numbered } from './notices.mjs';
|
|
60
60
|
import { noticeReceiptsInTurns, noticeKey, requireNoticeBasis, noticeWriterBasis } from './notice-receipts.mjs';
|
|
61
61
|
import { pendingRequestMail } from './request-mail.mjs';
|
|
62
62
|
import {
|
|
@@ -1939,8 +1939,10 @@ export async function sessionEffect(board, e) {
|
|
|
1939
1939
|
if (e.kind === 'send_to_actor') {
|
|
1940
1940
|
const row = view(board, db => e.runId ? db.prepare('SELECT metadata FROM task_runs WHERE id=?').get(e.runId) : null);
|
|
1941
1941
|
const session = json(row?.metadata, {})?.supercode;
|
|
1942
|
-
|
|
1943
|
-
|
|
1942
|
+
// an effect may name its recipient (a state request's requester, settleStateRequestAnswers); otherwise it is the
|
|
1943
|
+
// run's actor. A recipient in a person's custody is filed nothing, as the run's own detached session is not.
|
|
1944
|
+
if (view(board, db => e.address ? Boolean(personCustody(db, e.address)) || (addressOf(session) === e.address && detachedSession(db, session)) : detachedSession(db, session))) return true;
|
|
1945
|
+
const address = e.address ?? addressOf(session);
|
|
1944
1946
|
if (!address) throw new Error('actor session identity unavailable');
|
|
1945
1947
|
// filed in the actor's mailbox, which delivers it
|
|
1946
1948
|
const card = view(board, (db) => getTask(db, e.id));
|
|
@@ -3171,6 +3173,55 @@ function ensureSubscriptions(board, entry) {
|
|
|
3171
3173
|
});
|
|
3172
3174
|
}
|
|
3173
3175
|
|
|
3176
|
+
/**
|
|
3177
|
+
* Whether the session at `address` is known to have ended: one of card `taskId`'s runs carried it, and no run (of any
|
|
3178
|
+
* card) carrying it is unended or keeps it open after its run (retain_after_run, not since closed or handed on). One
|
|
3179
|
+
* session carries many runs and a kept session goes on asking after its run ends, so an ended run alone says nothing.
|
|
3180
|
+
*/
|
|
3181
|
+
function sessionEnded(db, taskId, address) {
|
|
3182
|
+
const runs = db.prepare("SELECT task_id, ended_at, metadata FROM task_runs WHERE json_extract(metadata, '$.supercode.address') = ? OR json_extract(metadata, '$.supercode.session_id') = ?")
|
|
3183
|
+
.all(address, address.split(':').at(-1)).map((r) => ({ ...r, session: json(r.metadata, {})?.supercode })).filter((r) => addressOf(r.session) === address);
|
|
3184
|
+
return runs.some((r) => r.task_id === taskId) && !runs.some((r) => r.ended_at == null
|
|
3185
|
+
|| (r.session.retain_after_run && !r.session.close_requested && !r.session.closed_at && !r.session.close_transferred_to));
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
/**
|
|
3189
|
+
* The manager's answer to a state request (its reply to the board's "State requests" question), granted or denied,
|
|
3190
|
+
* reaches the session that asked and stays on the card. A denial changes no card state, so before this the answer was
|
|
3191
|
+
* read from the mailbox only into the stall guard (card.state_request) and the requester went on reading its request
|
|
3192
|
+
* as pending. Each answer is recorded once as a `state_request_answered` event (the request, the answering message,
|
|
3193
|
+
* who sent it, its text and the state act the card took after the request, if any) and told to the requester, or the
|
|
3194
|
+
* card's live session (its current, unended run) when the request names none or the requester's session is known to
|
|
3195
|
+
* have ended (sessionEnded: run N asked and died, and the board relaunched the card as N+1), as an actor notice
|
|
3196
|
+
* through the effect queue (sessionEffect files it in that mailbox under a fixed key), queued in the transaction that
|
|
3197
|
+
* records it so neither lands without the other. A closed card with no live session gets the record alone. An answer
|
|
3198
|
+
* older than the workflow's notice_max_age_seconds, or whose age is unknown (no limit set, no reply time read), is
|
|
3199
|
+
* recorded, not mailed.
|
|
3200
|
+
*/
|
|
3201
|
+
function settleStateRequestAnswers(board, entry, report) {
|
|
3202
|
+
const maxAge = Number(entry?.params?.notice_max_age_seconds) || null;
|
|
3203
|
+
const self = boardAddress(board.label);
|
|
3204
|
+
tx(board, (db) => {
|
|
3205
|
+
for (const a of unrelayedStateAnswers(db, entry?.params?.notice_max_age_seconds)) {
|
|
3206
|
+
const outcome = a.act ? `the card took ${a.act} after the request` : 'the card\'s state is unchanged';
|
|
3207
|
+
const id = event(db, a.task_id, 'state_request_answered', { request_event: a.request_event, state: a.state, requester: a.requester,
|
|
3208
|
+
question: a.question, answer: a.answer, answered_by: a.answered_by, act: a.act, outcome, decision: String(a.text ?? '').slice(0, 4000) }, a.run_id);
|
|
3209
|
+
(report.state_answers ??= []).push([a.task_id, a.answer]);
|
|
3210
|
+
if (!maxAge || !Number(a.answered_at_ms) || Date.now() - Number(a.answered_at_ms) > maxAge * 1000) continue;
|
|
3211
|
+
const run = a.run_id ? db.prepare('SELECT ended_at, metadata FROM task_runs WHERE id = ?').get(a.run_id) : null;
|
|
3212
|
+
const live = run && run.ended_at == null ? addressOf(json(run.metadata, {})?.supercode) : null;
|
|
3213
|
+
const requester = String(a.requester ?? '').startsWith('sc:') ? a.requester : null;
|
|
3214
|
+
const to = requester && (requester === live || !sessionEnded(db, a.task_id, requester)) ? requester : live;
|
|
3215
|
+
if (!to || to === self || to === a.answered_by) continue;
|
|
3216
|
+
const text = `${a.task_id}: your request-state ${a.state ?? ''} (${a.question}) was answered by ${a.answered_by ?? 'the manager'} in ${a.answer}; ${outcome}.\n${String(a.text ?? '').trim()}\nRead the card: supercode workflow show ${a.task_id} --root ${board.root}`;
|
|
3217
|
+
const effect = { kind: 'send_to_actor', id: a.task_id, runId: a.run_id, address: to, sourceEvent: id, verb: 'state_request_answered',
|
|
3218
|
+
messageKey: `board-state-answer-${a.task_id}-${id}`,
|
|
3219
|
+
text: identifyNotice({ id, kind: 'state_request_answered', run_id: a.run_id, created_at: now() }, numbered(boardKey(board.root, board.slug), cardNumber(db, a.task_id), text)) };
|
|
3220
|
+
db.prepare('INSERT INTO supercode_effects (effect) VALUES (?)').run(JSON.stringify(effect));
|
|
3221
|
+
}
|
|
3222
|
+
});
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3174
3225
|
/** The event kinds an audience hears (the workflow's notify). */
|
|
3175
3226
|
const heardBy = (entry, audience) => Object.keys(entry.workflow.notify?.[audience] ?? {});
|
|
3176
3227
|
|
|
@@ -4040,7 +4091,7 @@ export async function tick({ root, lanes, settings, only = null, slugs = null, d
|
|
|
4040
4091
|
const phase = (name) => { const t = Date.now(); report.ms[name] = t - mark; mark = t; };
|
|
4041
4092
|
|
|
4042
4093
|
await readMailbox(board, boardAddress(board.label), report); phase('mailbox');
|
|
4043
|
-
await refreshRelationAnswers(board); phase('answers');
|
|
4094
|
+
await refreshRelationAnswers(board); settleStateRequestAnswers(board, entry, report); phase('answers');
|
|
4044
4095
|
const effects = [];
|
|
4045
4096
|
await completeAdoptions(board, fleet, settings, report, lanes); await reconnectContinuedSessions(board, fleet, entry, report); await reconcileLaunches(board, fleet, report, entry, effects, settings, lanes); await recordBounces(board, fleet, entry, report); await supervise(board, fleet, settings, report, entry, effects); queueEffects(board, effects.splice(0)); await closeRequested(board, report, { ttl: settings.ttl, fleet }); phase('supervise');
|
|
4046
4097
|
report.unsettled = [];
|
package/board/listing.mjs
CHANGED
|
@@ -103,11 +103,16 @@ export async function listCardsInTurns(db, options = {}) {
|
|
|
103
103
|
const cards = await readCardsInTurns(db, options, revision);
|
|
104
104
|
if (cards !== null) return cards;
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
// Both reads saw a write land between their turns: a burst, as the round a generation switch wakes makes. The third
|
|
107
|
+
// reads every batch in one turn, so a write by this process (the board's one writer) cannot land inside it; it still
|
|
108
|
+
// validates, so a writer outside this process is still refused. It holds the loop for one listing, and only here.
|
|
109
|
+
const cards = await readCardsInTurns(db, options, measured('read revision', () => listRevision(db)), { inTurns: false });
|
|
110
|
+
if (cards !== null) return cards;
|
|
111
|
+
throw Object.assign(new Error('board changed during all three listing reads; run list again'), { code: 'board_listing_changed' });
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
async function readCardsInTurns(db, { detailed = false, status = null, assignee = null, machine = null, includeArchived = false, includeSubtasks = false,
|
|
110
|
-
tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false }, revision) {
|
|
115
|
+
tenant = null, session = null, workflowTemplateId = null, stepKey = null, tags = [], nativeHolds = false }, revision, { inTurns = true } = {}) {
|
|
111
116
|
const out = [];
|
|
112
117
|
const page = prepared(db, listPageSql(detailed, tags.length, { nativeHolds })), candidates = prepared(db, LIST_CANDIDATES_SQL);
|
|
113
118
|
const ceiling = measured('last candidate', () => prepared(db, "SELECT COALESCE(MAX(id), '') AS cursor FROM tasks").get().cursor);
|
|
@@ -135,7 +140,7 @@ async function readCardsInTurns(db, { detailed = false, status = null, assignee
|
|
|
135
140
|
}
|
|
136
141
|
});
|
|
137
142
|
cursor = range.last;
|
|
138
|
-
await nextTurn();
|
|
143
|
+
if (inTurns) await nextTurn();
|
|
139
144
|
}
|
|
140
145
|
if (!measured('validate revision', () => sameRevision(revision, listRevision(db)))) return null;
|
|
141
146
|
// The candidate index uses id order; restore the original task row order for tied sorts.
|
package/board/workflow.mjs
CHANGED
|
@@ -342,11 +342,15 @@ export async function refreshRelationAnswers(board) {
|
|
|
342
342
|
for (const row of board.db.prepare("SELECT e.payload FROM task_events e JOIN (SELECT task_id, MAX(id) id FROM task_events WHERE kind IN ('blocked','unblocked') GROUP BY task_id) last ON e.id=last.id JOIN tasks t ON t.id=e.task_id WHERE e.kind='blocked' AND t.status='blocked'").all()) {
|
|
343
343
|
const id = json(row.payload, {})?.waiting_on; if (id) ids.add(id);
|
|
344
344
|
}
|
|
345
|
-
// State requests stay live while the card runs, independently of a blocker
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
345
|
+
// State requests stay live while the card runs, independently of a blocker, and a card's latest request is read
|
|
346
|
+
// until its answer has been relayed (granted or denied, settleStateRequestAnswers), on a closed card too while the
|
|
347
|
+
// request is recent (stateRequestCards). Read the ids the questions were mailed under through the same mailbox
|
|
348
|
+
// receipt door.
|
|
349
|
+
for (const row of stateRequestCards(board.db, entry?.params?.notice_max_age_seconds)) {
|
|
350
|
+
const request = row.current_run_id != null && !row.closed ? stateRequestOf(board.db, row.id) : null;
|
|
349
351
|
if (request?.message_id) ids.add(request.message_id);
|
|
352
|
+
const latest = latestStateRequest(board.db, row.id);
|
|
353
|
+
if (latest?.question && !latest.relayed) ids.add(latest.question);
|
|
350
354
|
}
|
|
351
355
|
const list = [...ids];
|
|
352
356
|
if (!list.length) return;
|
|
@@ -356,8 +360,9 @@ export async function refreshRelationAnswers(board) {
|
|
|
356
360
|
if (!Array.isArray(rows)) throw new Error('the mail door returned no message list');
|
|
357
361
|
for (const id of list) {
|
|
358
362
|
const row = rows.find((item) => item?.id === id);
|
|
363
|
+
const reply = row?.replies?.[0] ?? null;
|
|
359
364
|
mailboxAnswers.set(id, row?.found === true
|
|
360
|
-
? { answer:
|
|
365
|
+
? { answer: reply?.id ?? null, askedOf: row.session ?? null, by: reply?.from ?? null, at_ms: reply?.created_at_ms ?? null, text: reply?.body ?? null }
|
|
361
366
|
: { error: `the mailbox did not find question ${id}; its answer is unknown` });
|
|
362
367
|
}
|
|
363
368
|
} catch (error) { for (const id of list) mailboxAnswers.set(id, { error: `the answer to ${id} could not be read: ${String(error.message).slice(0, 200)}` }); }
|
|
@@ -370,16 +375,66 @@ function answerTo(id) {
|
|
|
370
375
|
|
|
371
376
|
// A manager's state act settles the request; a mailbox reply answers it without
|
|
372
377
|
// changing the card. Unknown mailbox reads never assert an unanswered question.
|
|
373
|
-
|
|
378
|
+
const STATE_ACTS = "('paused','resumed','blocked','unblocked','completed','cancelled','review_pass','reopened')";
|
|
379
|
+
// A card's latest state request and the id its question was mailed under. Requests asked within one batch window are
|
|
380
|
+
// mailed as one message under the batch's first id (request-mail.mjs), so a later member's own id was never sent and
|
|
381
|
+
// its answer lands under the batch's: board_message_sent records which (`request_id` the member's, `message_id` the
|
|
382
|
+
// sent one). `relayed` once its answer is on the card (state_request_answered).
|
|
383
|
+
function latestStateRequest(db, id) {
|
|
374
384
|
const row = one(db, "SELECT id,payload,created_at FROM task_events WHERE task_id=? AND kind='state_requested' ORDER BY id DESC LIMIT 1", id);
|
|
375
|
-
if (!row
|
|
385
|
+
if (!row) return null;
|
|
376
386
|
const payload = json(row.payload, {}) ?? {};
|
|
377
|
-
const
|
|
387
|
+
const asked = payload.message_id ?? json(one(db, "SELECT payload FROM task_events WHERE task_id=? AND id<? AND kind='board_message' AND json_extract(payload,'$.batch') IS NOT NULL ORDER BY id DESC LIMIT 1", id, row.id)?.payload, {})?.message_id;
|
|
388
|
+
const sent = asked ? one(db, "SELECT json_extract(payload,'$.message_id') AS id FROM task_events WHERE task_id=? AND id>? AND kind='board_message_sent' AND json_extract(payload,'$.request_id')=? ORDER BY id DESC LIMIT 1", id, row.id, asked)?.id : null;
|
|
389
|
+
const relayed = Boolean(one(db, "SELECT 1 FROM task_events WHERE task_id=? AND id>? AND kind='state_request_answered' AND json_extract(payload,'$.request_event')=? LIMIT 1", id, row.id, row.id));
|
|
390
|
+
return { row, payload, question: sent ?? asked ?? null, relayed };
|
|
391
|
+
}
|
|
392
|
+
function stateRequestOf(db, id) {
|
|
393
|
+
const latest = latestStateRequest(db, id);
|
|
394
|
+
if (!latest || one(db, `SELECT 1 FROM task_events WHERE task_id=? AND id>? AND kind IN ${STATE_ACTS} LIMIT 1`, id, latest.row.id)) return null;
|
|
395
|
+
const { row, payload, question } = latest;
|
|
378
396
|
const receipt = question ? mailboxAnswers.get(question) : null;
|
|
379
|
-
return { id: row.id, created_at: row.created_at, state: payload.state, reason: payload.reason, message_id: question
|
|
397
|
+
return { id: row.id, created_at: row.created_at, state: payload.state, reason: payload.reason, message_id: question,
|
|
380
398
|
answer_known: Boolean(receipt && !receipt.error), answer: receipt?.answer ?? null, error: receipt?.error ?? null };
|
|
381
399
|
}
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* The cards whose latest state request is still read: every open card, and a closed card (done, cancelled or archived:
|
|
403
|
+
* the manager may close it before a round reads the reply) whose latest request is younger than the workflow's
|
|
404
|
+
* notice_max_age_seconds, or STATE_ANSWER_WINDOW_SECONDS when it sets none, until its answer is recorded. A closed
|
|
405
|
+
* card's request older than that is no longer read, so an unanswered one is not read every round forever.
|
|
406
|
+
*/
|
|
407
|
+
const STATE_ANSWER_WINDOW_SECONDS = 3600;
|
|
408
|
+
function stateRequestCards(db, maxAgeSeconds) {
|
|
409
|
+
const since = now() - (Number(maxAgeSeconds) || STATE_ANSWER_WINDOW_SECONDS);
|
|
410
|
+
return all(db, `SELECT t.id, t.current_run_id, t.status IN ('done','cancelled','archived') AS closed FROM tasks t
|
|
411
|
+
LEFT JOIN (SELECT task_id, MAX(id) id, MAX(created_at) at FROM task_events WHERE kind = 'state_requested' GROUP BY task_id) r ON r.task_id = t.id
|
|
412
|
+
WHERE t.status NOT IN ('done','cancelled','archived')
|
|
413
|
+
OR (r.id IS NOT NULL AND r.at >= ?
|
|
414
|
+
AND NOT EXISTS (SELECT 1 FROM task_events a WHERE a.task_id = t.id AND a.id > r.id AND a.kind = 'state_request_answered' AND json_extract(a.payload,'$.request_event') = r.id))`, since);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Each card's latest state request (stateRequestCards) whose answer the mailbox has read and the card does not yet
|
|
419
|
+
* record: the manager's reply to the board's question, granted or denied, with who sent it, its text and the state
|
|
420
|
+
* act (if any) the card took after the request. The dispatcher records it and tells the requester (dispatch.mjs
|
|
421
|
+
* settleStateRequestAnswers); a denial changes no card state, so this reply is the only trace it leaves.
|
|
422
|
+
*/
|
|
423
|
+
export function unrelayedStateAnswers(db, maxAgeSeconds) {
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const task of stateRequestCards(db, maxAgeSeconds)) {
|
|
426
|
+
const latest = latestStateRequest(db, task.id);
|
|
427
|
+
if (!latest?.question || latest.relayed) continue;
|
|
428
|
+
const receipt = mailboxAnswers.get(latest.question);
|
|
429
|
+
if (!receipt?.answer) continue;
|
|
430
|
+
const act = one(db, `SELECT kind FROM task_events WHERE task_id=? AND id>? AND kind IN ${STATE_ACTS} ORDER BY id LIMIT 1`, task.id, latest.row.id)?.kind ?? null;
|
|
431
|
+
out.push({ task_id: task.id, run_id: task.current_run_id ?? null, request_event: latest.row.id,
|
|
432
|
+
state: latest.payload.state ?? null, requester: latest.payload.requester ?? null, question: latest.question,
|
|
433
|
+
answer: receipt.answer, answered_by: receipt.by ?? null, answered_at_ms: receipt.at_ms ?? null, text: receipt.text ?? null, act });
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
383
438
|
const OPEN_OUTCOME = /^(\s*[-*] )\[ \] (.+)$/;
|
|
384
439
|
const TICKED_OUTCOME = /^\s*[-*] \[[xX]\] .+$/;
|
|
385
440
|
/** Card bodies use Markdown headings and standalone labels such as `Acceptance:` and `Tasks:`. Every label ends
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.221",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@volter/supercode-teams": "^0.3.
|
|
39
|
+
"@volter/supercode-teams": "^0.3.377",
|
|
40
40
|
"@volter/supercode-terminal": "^0.3.12",
|
|
41
41
|
"@volter/supercode-harness-sdk": "^0.3.106",
|
|
42
42
|
"@volter/teams": "0.2.104",
|