toolroll 0.9.58 → 0.9.60
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/CHANGELOG.md +18 -0
- package/README.md +8 -6
- package/dist/action-ledger.js +10 -1
- package/dist/assignment-adapters.d.ts +1 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +13 -4
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +6 -2
- package/dist/assignment.js +13 -8
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +19 -19
- package/dist/builder.js +78 -27
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +4 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/design/tokens.css +234 -0
- package/dist/discord.js +2 -1
- package/dist/dispatch.js +1 -1
- package/dist/evidence.d.ts +22 -0
- package/dist/evidence.js +76 -17
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/lead-cli.js +4 -1
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +8 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +11 -3
- package/dist/mailbox.js +3 -2
- package/dist/mcp-core.js +1 -1
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +38 -17
- package/dist/password-bearer.d.ts +4 -4
- package/dist/password-bearer.js +2 -2
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/provider.d.ts +8 -0
- package/dist/provider.js +12 -1
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/scope.js +14 -0
- package/dist/serve.js +3 -2
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/render-chat.js +12 -2
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +30 -22
- package/dist/server/task-views.js +1 -0
- package/dist/server/tasks.js +3 -1
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +19 -0
- package/dist/steer-status.js +30 -0
- package/dist/store.d.ts +85 -8
- package/dist/store.js +203 -15
- package/dist/surface.js +2 -2
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/task-status.js +3 -1
- package/dist/team-updates.js +3 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +20 -1
- package/package.json +21 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.60 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
- **Steer a running build.** A note now reaches the agent while it works, and
|
|
6
|
+
shows when the agent has read it. Notes it misses go to the next attempt.
|
|
7
|
+
- **One inbox for the lead.** Every update, including cancellations, reaches
|
|
8
|
+
the lead through one inbox.
|
|
9
|
+
- **Lighter status work.** Status sweeps skip when nothing changed, and
|
|
10
|
+
`--version` and `--help` answer instantly.
|
|
11
|
+
- **Signal tokens.** The console's colours and type come from the generated
|
|
12
|
+
Signal design tokens.
|
|
13
|
+
|
|
14
|
+
## 0.9.59 — 2026-10-10
|
|
15
|
+
|
|
16
|
+
- **Safer deploys.** A deploy that fails before the swap releases its own
|
|
17
|
+
lock. A stranded lock clears with one guided command.
|
|
18
|
+
- **Planning restarts on a new scope.** Changing a task's scope restarts its
|
|
19
|
+
planning. Cancelling a task closes its open questions.
|
|
20
|
+
|
|
3
21
|
## 0.9.58 — 2026-10-09
|
|
4
22
|
|
|
5
23
|
- **One token kind.** Personal, lead and coordinator tokens now live in one
|
package/README.md
CHANGED
|
@@ -100,9 +100,10 @@ separate model reviewer or automatic evidence resubmission loop. Deployment is
|
|
|
100
100
|
reported separately and still requires the exact passing native machine check.
|
|
101
101
|
|
|
102
102
|
Enable **Automatic crew updates** in a conversation, or use `toolroll chat
|
|
103
|
-
--follow`. Meaningful updates
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
--follow`. Meaningful updates (a result, a question, completion or cancellation)
|
|
104
|
+
reach the same lead within its saved permissions and limits, through the same
|
|
105
|
+
acknowledged inbox an external coordinator reads. Idle scanning uses no model.
|
|
106
|
+
Restart recovery reuses saved responses; a failed response does not rerun a task. `--no-follow` pauses automatic responses.
|
|
106
107
|
This wakes Toolroll's own lead, not an unrelated external agent session.
|
|
107
108
|
|
|
108
109
|
`toolroll brief` reads the local database. `knowledge search`, `knowledge
|
|
@@ -816,7 +817,7 @@ The console, the terminal, Telegram, Slack, Discord and Teams all talk to the
|
|
|
816
817
|
same lead with the same tools. The lead reads the fleet
|
|
817
818
|
and **only proposes**: file a task (or a scout), move one to the front,
|
|
818
819
|
reserve it for a worker, hold it, rewrite a scope, retry/replace/unlink a
|
|
819
|
-
terminal dependency,
|
|
820
|
+
terminal dependency, send a task's agent guidance, cancel, or suggest an answer to a parked decision. Every
|
|
820
821
|
proposal is a card you confirm, with
|
|
821
822
|
every consequence and the builder's recommendation shown beside the
|
|
822
823
|
lead's pick; a scope the lead wrote never seals under an operating mode.
|
|
@@ -843,8 +844,9 @@ Every task has an **Overview / Ask** switch. **Ask** opens a focused companion
|
|
|
843
844
|
to that task without creating another conversation: Toolroll attaches
|
|
844
845
|
the current task to each new message, keeps the live status beside the thread,
|
|
845
846
|
and offers plain-language starters for status, scope revision, steering, and
|
|
846
|
-
result inspection. Proposed guidance is inert until you confirm its card
|
|
847
|
-
|
|
847
|
+
result inspection. Proposed guidance is inert until you confirm its card. A
|
|
848
|
+
running build's agent then reads it when it next checks for notes; otherwise
|
|
849
|
+
the next attempt does. Each note says whether the agent has read it.
|
|
848
850
|
|
|
849
851
|
New work starts the same way: describe the outcome once in ordinary language.
|
|
850
852
|
The lead infers a concise title, narrow scope, safe non-goals, and proof
|
package/dist/action-ledger.js
CHANGED
|
@@ -36,6 +36,11 @@ export function installLedgerTriggers(db) {
|
|
|
36
36
|
const task = (ref) => `(SELECT external_id FROM task_ref WHERE id = ${ref})`;
|
|
37
37
|
const runRef = `(SELECT task_ref FROM run WHERE id = NEW.run)`;
|
|
38
38
|
const event = (at, actor, project, taskId, runId, action, outcome) => `INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source) VALUES (${at},${actor},${project},${taskId},${runId},'${action}',${outcome},'work');`;
|
|
39
|
+
// v119: closing a question without an answer is its own event. Replace only the old trigger's future behavior;
|
|
40
|
+
// every ledger entry already recorded, and its seal, stays unchanged.
|
|
41
|
+
const answeredTrigger = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'ledger_decision_answered'").get();
|
|
42
|
+
if (answeredTrigger !== undefined && !String(answeredTrigger["sql"]).includes("superseded_reason"))
|
|
43
|
+
db.exec("DROP TRIGGER ledger_decision_answered");
|
|
39
44
|
db.exec(`
|
|
40
45
|
CREATE TRIGGER IF NOT EXISTS ledger_run_started AFTER INSERT ON run BEGIN
|
|
41
46
|
${event("NEW.started_at", "NEW.runner", repo("NEW.task_ref"), task("NEW.task_ref"), "NEW.id", "run started", "NEW.role")}
|
|
@@ -48,9 +53,13 @@ CREATE TRIGGER IF NOT EXISTS ledger_decision_opened AFTER INSERT ON decision BEG
|
|
|
48
53
|
${event("NEW.created_at", "'system'", repo(runRef), task(runRef), "NEW.run", "decision opened", "NEW.state")}
|
|
49
54
|
END;
|
|
50
55
|
CREATE TRIGGER IF NOT EXISTS ledger_decision_answered AFTER UPDATE OF answered_at ON decision
|
|
51
|
-
WHEN NEW.answered_at IS NOT NULL AND OLD.answered_at IS NULL BEGIN
|
|
56
|
+
WHEN NEW.answered_at IS NOT NULL AND OLD.answered_at IS NULL AND NEW.superseded_reason IS NULL BEGIN
|
|
52
57
|
${event("NEW.answered_at", "COALESCE(NEW.answered_by,'system')", repo(runRef), task(runRef), "NEW.run", "decision answered", "NEW.state")}
|
|
53
58
|
END;
|
|
59
|
+
CREATE TRIGGER IF NOT EXISTS ledger_decision_closed AFTER UPDATE OF answered_at ON decision
|
|
60
|
+
WHEN NEW.answered_at IS NOT NULL AND OLD.answered_at IS NULL AND NEW.superseded_reason IS NOT NULL BEGIN
|
|
61
|
+
${event("NEW.answered_at", "COALESCE(NEW.answered_by,'system')", repo(runRef), task(runRef), "NEW.run", "decision closed", "NEW.superseded_reason")}
|
|
62
|
+
END;
|
|
54
63
|
CREATE TRIGGER IF NOT EXISTS ledger_scope_approved AFTER UPDATE OF approved_at, approved_digest ON task_scope
|
|
55
64
|
WHEN NEW.approved_at IS NOT NULL AND (OLD.approved_at IS NOT NEW.approved_at OR OLD.approved_digest IS NOT NEW.approved_digest) BEGIN
|
|
56
65
|
${event("NEW.approved_at", "COALESCE(NEW.approved_by,'system')", "(SELECT repo FROM task_ref WHERE backend = 'built-in' AND external_id = NEW.task_id)", "NEW.task_id", "NULL", "scope approved", "'approved'")}
|
|
@@ -18,7 +18,7 @@ export declare const ASSIGNMENT_TOOLS: readonly [{
|
|
|
18
18
|
}, {
|
|
19
19
|
readonly name: "list_assignment_updates";
|
|
20
20
|
readonly operation: "updates";
|
|
21
|
-
readonly description: "Read
|
|
21
|
+
readonly description: "Read the history of assignment handoffs sent to people in your projects, after a cursor. History only: it delivers and acknowledges nothing. To receive your updates, use get_assignment_inbox.";
|
|
22
22
|
}, {
|
|
23
23
|
readonly name: "claim_assignment";
|
|
24
24
|
readonly operation: "claim";
|
|
@@ -16,7 +16,7 @@ const failure = (reason, message) => ({ ok: false, reason, message });
|
|
|
16
16
|
* (src/contracts/gateway-tools.ts), which `toolroll assignment` reads its flags with too. */
|
|
17
17
|
export const ASSIGNMENT_TOOLS = [
|
|
18
18
|
{ name: "get_assignment", operation: "show", description: "Read one assignment's root, current execution, owner, exact result and handoff receipt. Approval, proof and deployment remain separate facts." },
|
|
19
|
-
{ name: "list_assignment_updates", operation: "updates", description: "Read
|
|
19
|
+
{ name: "list_assignment_updates", operation: "updates", description: "Read the history of assignment handoffs sent to people in your projects, after a cursor. History only: it delivers and acknowledges nothing. To receive your updates, use get_assignment_inbox." },
|
|
20
20
|
{ name: "claim_assignment", operation: "claim", description: "Record yourself as this assignment's lead. Repeating your claim is safe; another active owner cannot be replaced. This grants no approval or execution authority." },
|
|
21
21
|
{ name: "acknowledge_assignment", operation: "check", description: "Acknowledge the exact ready-to-check receipt as its lead, using the current receipt digest. This does not accept failed proof, answer decisions, approve work, publish or deploy." },
|
|
22
22
|
{ name: "get_assignment_brief", operation: "brief", description: "Catch up from the local database: current work, decisions, saved results and project knowledge. Bounded read only; no model call or task mutation." },
|
|
@@ -47,10 +47,26 @@ export declare function assignmentInbox(store: Store, owner: AssignmentOwner, qu
|
|
|
47
47
|
consumer: string;
|
|
48
48
|
limit?: number;
|
|
49
49
|
}, now: Date): AssignmentInboxResult;
|
|
50
|
+
/** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
|
|
51
|
+
export declare function leadFollowInbox(store: Store, grant: {
|
|
52
|
+
id: number;
|
|
53
|
+
repos: readonly string[];
|
|
54
|
+
}, query: {
|
|
55
|
+
consumer: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
}, now: Date): AssignmentInboxResult;
|
|
50
58
|
/** ACK advances only the exact issued page, atomically. Replaying any prior
|
|
51
59
|
* ACK is harmless, including while a newer page awaits acknowledgment. */
|
|
52
60
|
export declare function acknowledgeAssignmentDelivery(store: Store, owner: AssignmentOwner, query: {
|
|
53
61
|
consumer: string;
|
|
54
62
|
batchId: string;
|
|
55
63
|
}, now: Date): AssignmentDeliveryAckResult;
|
|
64
|
+
/** The built-in lead acknowledges its own page the same way, once it has handled it. */
|
|
65
|
+
export declare function acknowledgeLeadFollowDelivery(store: Store, grant: {
|
|
66
|
+
id: number;
|
|
67
|
+
repos: readonly string[];
|
|
68
|
+
}, query: {
|
|
69
|
+
consumer: string;
|
|
70
|
+
batchId: string;
|
|
71
|
+
}, now: Date): AssignmentDeliveryAckResult;
|
|
56
72
|
export {};
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/** Durable, explicitly acknowledged delivery of assignment status events.
|
|
2
2
|
* Callers authenticate the raw coordinator credential inside the same Store
|
|
3
|
-
* transaction. Delivery receipts never complete tasks or grant authority.
|
|
3
|
+
* transaction. Delivery receipts never complete tasks or grant authority.
|
|
4
|
+
* This is the one way results reach a lead: an external coordinator reads its
|
|
5
|
+
* owned assignments' events, and the built-in lead reads its follow grant's
|
|
6
|
+
* (lead-follow.ts) through the same saved pages, replay and acknowledgment. */
|
|
4
7
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
-
import { ASSIGNMENT_STATUS_ACTION, parseAssignmentStatusEvent } from "./assignment-status.js";
|
|
8
|
+
import { ASSIGNMENT_STATUS_ACTION, followActor, parseAssignmentStatusEvent } from "./assignment-status.js";
|
|
6
9
|
const MAX_PAGE_BYTES = 64 * 1024;
|
|
7
10
|
const fail = (reason, message) => ({ ok: false, reason, message });
|
|
8
|
-
const actor = (owner) => `coordinator:${owner.id}`;
|
|
9
|
-
const keyFor = (owner, consumer) => createHash("sha256").update(JSON.stringify([owner.id, consumer])).digest("hex");
|
|
10
11
|
const validConsumer = (value) => typeof value === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(value);
|
|
11
12
|
const ownerSql = `COALESCE((SELECT 'lead:'||o.lead FROM team_task_owner o WHERE o.task_ref=t.id),(SELECT a.actor FROM action_ledger a WHERE a.task_id = t.external_id
|
|
12
13
|
AND a.action = 'assignment claimed' AND a.source = 'work' ORDER BY a.id DESC LIMIT 1),
|
|
13
14
|
'coordinator:' || t.coordinator_cid)`;
|
|
15
|
+
function coordinatorReader(store, owner) {
|
|
16
|
+
const actor = `coordinator:${owner.id}`;
|
|
17
|
+
return { actor, key: consumer => createHash("sha256").update(JSON.stringify([owner.id, consumer])).digest("hex"),
|
|
18
|
+
repos: () => admittedRepos(store, owner), admits: { sql: `${ownerSql} = ?`, params: [actor] },
|
|
19
|
+
receives: event => event.assignment.owner?.id === owner.id, gone: "The coordinator is no longer active." };
|
|
20
|
+
}
|
|
21
|
+
/** The built-in lead's follow grant (lead-follow.ts), which its caller has already proved, with the projects it covers. */
|
|
22
|
+
function followReader(grant) {
|
|
23
|
+
return { actor: followActor(grant.id), key: consumer => createHash("sha256").update(JSON.stringify(["follow", grant.id, consumer])).digest("hex"),
|
|
24
|
+
repos: () => [...grant.repos], admits: { sql: "1 = 1", params: [] }, receives: () => true, gone: "Automatic crew updates are off." };
|
|
25
|
+
}
|
|
14
26
|
function admittedRepos(store, owner) {
|
|
15
27
|
if (owner.kind !== "coordinator" || typeof owner.id !== "string" || owner.id.length === 0)
|
|
16
28
|
return null;
|
|
@@ -25,28 +37,28 @@ function admittedRepos(store, owner) {
|
|
|
25
37
|
return null;
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
|
-
function exposedBatch(store,
|
|
40
|
+
function exposedBatch(store, reader, repos, batch) {
|
|
29
41
|
return { ...batch, events: batch.events.map(event => {
|
|
30
42
|
const admitted = repos.includes(event.project) && store.handle.prepare(`SELECT 1 FROM task_ref t
|
|
31
|
-
WHERE t.backend = 'built-in' AND t.external_id = ? AND t.repo = ? AND t.revision_of IS NULL AND ${
|
|
32
|
-
.get(event.rootId, event.project,
|
|
43
|
+
WHERE t.backend = 'built-in' AND t.external_id = ? AND t.repo = ? AND t.revision_of IS NULL AND ${reader.admits.sql}`)
|
|
44
|
+
.get(event.rootId, event.project, ...reader.admits.params) !== undefined;
|
|
33
45
|
return admitted ? event : { id: event.id, unavailable: true };
|
|
34
46
|
}) };
|
|
35
47
|
}
|
|
36
|
-
function eventFromRow(row,
|
|
48
|
+
function eventFromRow(row, reader) {
|
|
37
49
|
const parsed = parseAssignmentStatusEvent(String(row["outcome"]));
|
|
38
|
-
if (parsed === null || parsed.assignment.rootId !== row["task_id"] || parsed
|
|
50
|
+
if (parsed === null || parsed.assignment.rootId !== row["task_id"] || !reader.receives(parsed))
|
|
39
51
|
return null;
|
|
40
52
|
return { id: Number(row["id"]), createdAt: String(row["at"]), rootId: String(row["task_id"]),
|
|
41
53
|
project: String(row["repo"]), kind: "assignment-status", digest: parsed.digest, assignment: parsed.assignment };
|
|
42
54
|
}
|
|
43
|
-
function pendingBatch(store,
|
|
55
|
+
function pendingBatch(store, reader, key) {
|
|
44
56
|
const id = store.serviceCursor(`assignment-inbox:${key}:pending`);
|
|
45
57
|
if (id === 0)
|
|
46
58
|
return null;
|
|
47
59
|
const invalid = () => fail("unavailable", "The saved delivery page is invalid; its cursor has not advanced.");
|
|
48
60
|
const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE id = ? AND actor = ? AND action = ? AND source = 'work'")
|
|
49
|
-
.get(id, actor
|
|
61
|
+
.get(id, reader.actor, `assignment delivery batch:${key}`);
|
|
50
62
|
if (!row || Buffer.byteLength(String(row["outcome"])) > MAX_PAGE_BYTES)
|
|
51
63
|
return invalid();
|
|
52
64
|
let parsed;
|
|
@@ -69,8 +81,8 @@ function pendingBatch(store, owner, key) {
|
|
|
69
81
|
if (!event || !Number.isSafeInteger(event.id) || event.id <= previous)
|
|
70
82
|
return invalid();
|
|
71
83
|
const source = store.handle.prepare("SELECT id,at,task_id,repo,outcome FROM action_ledger WHERE id = ? AND actor = ? AND action = ? AND source = 'work'")
|
|
72
|
-
.get(event.id, actor
|
|
73
|
-
const original = source === undefined ? null : eventFromRow(source,
|
|
84
|
+
.get(event.id, reader.actor, ASSIGNMENT_STATUS_ACTION);
|
|
85
|
+
const original = source === undefined ? null : eventFromRow(source, reader);
|
|
74
86
|
if (original === null || JSON.stringify(original) !== JSON.stringify(event))
|
|
75
87
|
return invalid();
|
|
76
88
|
previous = event.id;
|
|
@@ -83,31 +95,38 @@ function pendingBatch(store, owner, key) {
|
|
|
83
95
|
* tombstone; it does not prevent acknowledgment of that already issued page.
|
|
84
96
|
* Consumer names identify independent inboxes. */
|
|
85
97
|
export function assignmentInbox(store, owner, query, now) {
|
|
98
|
+
return inbox(store, coordinatorReader(store, owner), query, now);
|
|
99
|
+
}
|
|
100
|
+
/** The built-in lead's inbox: the same page, replay and acknowledgment as a coordinator's, over its follow grant. */
|
|
101
|
+
export function leadFollowInbox(store, grant, query, now) {
|
|
102
|
+
return inbox(store, followReader(grant), query, now);
|
|
103
|
+
}
|
|
104
|
+
function inbox(store, reader, query, now) {
|
|
86
105
|
if (!validConsumer(query.consumer) || (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 100)))
|
|
87
106
|
return fail("invalid-input", "Use a consumer name of 1–64 characters and a page size of 1–100.");
|
|
88
107
|
return store.transact(() => store.savepoint(() => {
|
|
89
|
-
const repos =
|
|
108
|
+
const repos = reader.repos();
|
|
90
109
|
if (repos === null)
|
|
91
|
-
return fail("unavailable",
|
|
92
|
-
const key =
|
|
93
|
-
const after = store.serviceCursor(cursorKey), pending = pendingBatch(store,
|
|
110
|
+
return fail("unavailable", reader.gone);
|
|
111
|
+
const key = reader.key(query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
|
|
112
|
+
const after = store.serviceCursor(cursorKey), pending = pendingBatch(store, reader, key);
|
|
94
113
|
if (pending !== null) {
|
|
95
114
|
if ("ok" in pending)
|
|
96
115
|
return pending;
|
|
97
116
|
if (pending.consumer !== query.consumer || pending.after !== after)
|
|
98
117
|
return fail("unavailable", "The saved delivery page does not match its cursor.");
|
|
99
|
-
return { ok: true, batch: exposedBatch(store,
|
|
118
|
+
return { ok: true, batch: exposedBatch(store, reader, repos, pending), replayed: true };
|
|
100
119
|
}
|
|
101
120
|
const limit = query.limit ?? 10;
|
|
102
121
|
const rows = store.handle.prepare(`SELECT n.id,n.at,n.task_id,n.repo,n.outcome
|
|
103
122
|
FROM action_ledger n JOIN task_ref t ON t.backend = 'built-in'
|
|
104
123
|
AND t.external_id = n.task_id AND t.repo = n.repo AND t.revision_of IS NULL
|
|
105
124
|
WHERE n.id > ? AND n.action = ? AND n.source = 'work' AND n.actor = ?
|
|
106
|
-
AND n.repo IN (SELECT value FROM json_each(?)) AND ${
|
|
107
|
-
ORDER BY n.id LIMIT ?`).all(after, ASSIGNMENT_STATUS_ACTION, actor
|
|
125
|
+
AND n.repo IN (SELECT value FROM json_each(?)) AND ${reader.admits.sql}
|
|
126
|
+
ORDER BY n.id LIMIT ?`).all(after, ASSIGNMENT_STATUS_ACTION, reader.actor, JSON.stringify(repos), ...reader.admits.params, limit + 1);
|
|
108
127
|
const batch = { version: 1, id: null, consumer: query.consumer, after, nextCursor: after, hasMore: rows.length > limit, events: [] };
|
|
109
128
|
for (const row of rows.slice(0, limit)) {
|
|
110
|
-
const event = eventFromRow(row,
|
|
129
|
+
const event = eventFromRow(row, reader);
|
|
111
130
|
if (event === null)
|
|
112
131
|
return fail("unavailable", "The next saved assignment event is invalid; no event was skipped.");
|
|
113
132
|
batch.events.push(event);
|
|
@@ -123,7 +142,7 @@ export function assignmentInbox(store, owner, query, now) {
|
|
|
123
142
|
if (batch.events.length === 0)
|
|
124
143
|
return { ok: true, batch, replayed: false };
|
|
125
144
|
batch.id = randomBytes(16).toString("hex");
|
|
126
|
-
const id = store.recordAction({ at: now.toISOString(), actor: actor
|
|
145
|
+
const id = store.recordAction({ at: now.toISOString(), actor: reader.actor, repo: null, taskId: null, runId: null,
|
|
127
146
|
action: `assignment delivery batch:${key}`, outcome: JSON.stringify(batch), source: "work" });
|
|
128
147
|
store.setServiceCursor(`assignment-inbox:${key}:pending`, id, now);
|
|
129
148
|
return { ok: true, batch, replayed: false };
|
|
@@ -132,26 +151,33 @@ export function assignmentInbox(store, owner, query, now) {
|
|
|
132
151
|
/** ACK advances only the exact issued page, atomically. Replaying any prior
|
|
133
152
|
* ACK is harmless, including while a newer page awaits acknowledgment. */
|
|
134
153
|
export function acknowledgeAssignmentDelivery(store, owner, query, now) {
|
|
154
|
+
return acknowledge(store, coordinatorReader(store, owner), query, now);
|
|
155
|
+
}
|
|
156
|
+
/** The built-in lead acknowledges its own page the same way, once it has handled it. */
|
|
157
|
+
export function acknowledgeLeadFollowDelivery(store, grant, query, now) {
|
|
158
|
+
return acknowledge(store, followReader(grant), query, now);
|
|
159
|
+
}
|
|
160
|
+
function acknowledge(store, reader, query, now) {
|
|
135
161
|
if (!validConsumer(query.consumer) || typeof query.batchId !== "string" || !/^[a-f0-9]{32}$/.test(query.batchId))
|
|
136
162
|
return fail("invalid-input", "Use the exact consumer and batch ID returned by the inbox.");
|
|
137
163
|
return store.transact(() => store.savepoint(() => {
|
|
138
|
-
const repos =
|
|
164
|
+
const repos = reader.repos();
|
|
139
165
|
if (repos === null)
|
|
140
|
-
return fail("unavailable",
|
|
141
|
-
const key =
|
|
166
|
+
return fail("unavailable", reader.gone);
|
|
167
|
+
const key = reader.key(query.consumer), cursorKey = `assignment-inbox:${key}:acked`;
|
|
142
168
|
const cursor = store.serviceCursor(cursorKey), action = `assignment delivery acknowledged:${key}`;
|
|
143
169
|
const acknowledged = store.handle.prepare(`SELECT 1 FROM action_ledger WHERE actor = ? AND action = ? AND source = 'work'
|
|
144
|
-
AND json_extract(outcome, '$.batchId') = ? LIMIT 1`).get(actor
|
|
170
|
+
AND json_extract(outcome, '$.batchId') = ? LIMIT 1`).get(reader.actor, action, query.batchId);
|
|
145
171
|
if (acknowledged)
|
|
146
172
|
return { ok: true, alreadyAcknowledged: true, cursor };
|
|
147
|
-
const pending = pendingBatch(store,
|
|
173
|
+
const pending = pendingBatch(store, reader, key);
|
|
148
174
|
if (pending !== null && "ok" in pending)
|
|
149
175
|
return pending;
|
|
150
176
|
if (pending === null || pending.id !== query.batchId || pending.consumer !== query.consumer || pending.after !== cursor)
|
|
151
177
|
return fail("stale-batch", "This batch was not issued to this inbox; its cursor has not advanced.");
|
|
152
178
|
// Access loss redacts replayed events; the exact issued page remains
|
|
153
179
|
// acknowledgeable so its inaccessible entries cannot wedge this inbox.
|
|
154
|
-
store.recordAction({ at: now.toISOString(), actor: actor
|
|
180
|
+
store.recordAction({ at: now.toISOString(), actor: reader.actor, repo: null, taskId: null, runId: null,
|
|
155
181
|
action, outcome: JSON.stringify({ batchId: pending.id, after: pending.after, cursor: pending.nextCursor }), source: "work" });
|
|
156
182
|
store.setServiceCursor(cursorKey, pending.nextCursor, now);
|
|
157
183
|
store.setServiceCursor(`assignment-inbox:${key}:pending`, 0, now);
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
2
|
import { type AssignmentSnapshot } from "./assignment.js";
|
|
3
3
|
export declare const ASSIGNMENT_STATUS_ACTION = "assignment status observed";
|
|
4
|
+
/** A reader's first sight of a task that is not news: the state later events are compared to, never delivered. */
|
|
5
|
+
export declare const ASSIGNMENT_STATUS_BASELINE = "assignment status baseline";
|
|
4
6
|
export declare const ASSIGNMENT_STATUS_SOURCE: "work";
|
|
7
|
+
/** The states that wake a lead: a result to check, a question, completion, or cancellation. */
|
|
8
|
+
export declare const LEAD_WAKE_STATES: readonly AssignmentSnapshot["state"][];
|
|
9
|
+
/** The built-in lead's events, one stream per follow grant. */
|
|
10
|
+
export declare const followActor: (grant: number) => string;
|
|
5
11
|
/** No saved artifact contents, credential bytes, or polling timestamps. Exact
|
|
6
12
|
* IDs and digests identify the full read; display text is deliberately bounded. */
|
|
7
13
|
export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
|
|
@@ -42,13 +48,12 @@ export declare function assignmentStatusBrief(assignment: AssignmentSnapshot): {
|
|
|
42
48
|
remoteState: string | null;
|
|
43
49
|
} | null;
|
|
44
50
|
};
|
|
51
|
+
/** An owner's events always name it; the built-in lead's follow events name whatever owner the task has, or none. */
|
|
45
52
|
export type AssignmentStatusEvent = {
|
|
46
53
|
version: 1;
|
|
47
54
|
digest: string;
|
|
48
55
|
stateDigest: string;
|
|
49
|
-
assignment: ReturnType<typeof assignmentStatusBrief
|
|
50
|
-
owner: NonNullable<ReturnType<typeof assignmentStatusBrief>["owner"]>;
|
|
51
|
-
};
|
|
56
|
+
assignment: ReturnType<typeof assignmentStatusBrief>;
|
|
52
57
|
};
|
|
53
58
|
/** An unreadable ledger event is an explicit consumer error, not a cursor that
|
|
54
59
|
* may silently skip the row. This validates the bounded data, not authority. */
|
|
@@ -57,6 +62,10 @@ export declare function parseAssignmentStatusEvent(value: string): AssignmentSta
|
|
|
57
62
|
* for worker reconciliation. The latest event, not a permanent digest set,
|
|
58
63
|
* deduplicates retries so Working → Needs decision → Working is retained. */
|
|
59
64
|
export declare function noteAssignmentStatus(store: Store, snapshot: AssignmentSnapshot, now: Date): number | null;
|
|
65
|
+
/** The built-in lead's crew update (lead-follow.ts): the same event, under its follow grant. The
|
|
66
|
+
* caller has already proved the grant and its projects; the root must still be a placed task. */
|
|
67
|
+
export declare function noteFollowStatus(store: Store, grant: number, snapshot: AssignmentSnapshot, now: Date, action?: typeof ASSIGNMENT_STATUS_ACTION | typeof ASSIGNMENT_STATUS_BASELINE): number | null;
|
|
60
68
|
/** At most fifty roots per pass; the cursor survives process restarts. A full
|
|
61
|
-
* pass cycles back, so changes below the cursor are eventually observed too.
|
|
69
|
+
* pass cycles back, so changes below the cursor are eventually observed too.
|
|
70
|
+
* A pass is skipped when nothing moved since the cycle began (sweep-gate.ts). */
|
|
62
71
|
export declare function syncAssignmentStatuses(store: Store, now: Date, repos: readonly string[], root?: string): void;
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/** Private lead catch-up, separate from human notification delivery. These are
|
|
2
2
|
* observed assignment transitions, not a promise to capture every intermediate
|
|
3
|
-
* state between scans. Recording or reading them never dispatches agent work.
|
|
3
|
+
* state between scans. Recording or reading them never dispatches agent work.
|
|
4
|
+
* One stream serves every lead: a coordinator's or team lead's events are
|
|
5
|
+
* recorded under its owner, and the built-in lead's crew updates under its
|
|
6
|
+
* follow grant (`follow:<grant>`, lead-follow.ts); each reads its own through
|
|
7
|
+
* the delivery inbox (assignment-delivery.ts). */
|
|
4
8
|
import { createHash } from "node:crypto";
|
|
5
9
|
import { assignmentOf } from "./assignment.js";
|
|
6
10
|
import { publicChatText } from "./chat-display.js";
|
|
11
|
+
import { sweepIfMoved } from "./sweep-gate.js";
|
|
7
12
|
export const ASSIGNMENT_STATUS_ACTION = "assignment status observed";
|
|
13
|
+
/** A reader's first sight of a task that is not news: the state later events are compared to, never delivered. */
|
|
14
|
+
export const ASSIGNMENT_STATUS_BASELINE = "assignment status baseline";
|
|
8
15
|
export const ASSIGNMENT_STATUS_SOURCE = "work";
|
|
16
|
+
/** The states that wake a lead: a result to check, a question, completion, or cancellation. */
|
|
17
|
+
export const LEAD_WAKE_STATES = ["ready-to-check", "needs-decision", "complete", "cancelled"];
|
|
18
|
+
/** The built-in lead's events, one stream per follow grant. */
|
|
19
|
+
export const followActor = (grant) => `follow:${grant}`;
|
|
9
20
|
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
10
21
|
const text = publicChatText;
|
|
11
22
|
/** No saved artifact contents, credential bytes, or polling timestamps. Exact
|
|
@@ -51,7 +62,7 @@ export function parseAssignmentStatusEvent(value) {
|
|
|
51
62
|
!Array.isArray(a["attention"]) || a["attention"].length > 8 || !a["attention"].every(one => string(one, 500)))
|
|
52
63
|
return null;
|
|
53
64
|
const owner = a["owner"], action = a["primaryAction"], result = a["result"], completion = a["completion"], publication = a["publication"];
|
|
54
|
-
if (!object(owner) || !keys(owner, ["kind", "id", "label"]) || !["coordinator", "lead"].includes(String(owner["kind"])) || !string(owner["id"], 128) || !string(owner["label"], 100))
|
|
65
|
+
if (owner !== null && (!object(owner) || !keys(owner, ["kind", "id", "label"]) || !["coordinator", "lead"].includes(String(owner["kind"])) || !string(owner["id"], 128) || !string(owner["label"], 100)))
|
|
55
66
|
return null;
|
|
56
67
|
if (action !== null && (!object(action) || !keys(action, ["code", "label", "target"]) || !string(action["code"], 80) || !string(action["label"], 160) ||
|
|
57
68
|
!object(action["target"]) || !keys(action["target"], ["taskId", "runId", "decisionId"]) || !string(action["target"]["taskId"], 64) || !id(action["target"]["runId"]) || !id(action["target"]["decisionId"])))
|
|
@@ -103,50 +114,70 @@ export function noteAssignmentStatus(store, snapshot, now) {
|
|
|
103
114
|
if (!Array.isArray(repos) || !repos.includes(snapshot.repo))
|
|
104
115
|
return null;
|
|
105
116
|
}
|
|
106
|
-
|
|
107
|
-
if (assignment.owner === null)
|
|
117
|
+
if (assignmentStatusBrief(snapshot).owner === null)
|
|
108
118
|
return null;
|
|
109
|
-
|
|
110
|
-
const previous = store.handle.prepare("SELECT id,outcome FROM action_ledger WHERE task_id=? AND action=? AND source=? ORDER BY id DESC LIMIT 1")
|
|
111
|
-
.get(snapshot.rootId, ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_SOURCE);
|
|
112
|
-
if (previous !== undefined) {
|
|
113
|
-
try {
|
|
114
|
-
if (JSON.parse(String(previous["outcome"]))?.stateDigest === stateDigest)
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
catch { /* Preserve an unreadable older event; append the current observation. */ }
|
|
118
|
-
}
|
|
119
|
-
const digest = hash({ rootRef: ref.id, owner: owner.id, previous: previous === undefined ? null : Number(previous["id"]), stateDigest });
|
|
120
|
-
const event = { version: 1, digest, stateDigest, assignment: { ...assignment, owner: assignment.owner } };
|
|
121
|
-
return store.recordAction({ at: now.toISOString(), actor: `${owner.kind}:${owner.id}`, repo: snapshot.repo,
|
|
122
|
-
taskId: snapshot.rootId, runId: assignment.runId, action: ASSIGNMENT_STATUS_ACTION,
|
|
123
|
-
outcome: JSON.stringify(event), source: ASSIGNMENT_STATUS_SOURCE });
|
|
119
|
+
return appendStatusEvent(store, `${owner.kind}:${owner.id}`, owner.id, ref.id, snapshot, now);
|
|
124
120
|
});
|
|
125
121
|
}
|
|
122
|
+
/** The built-in lead's crew update (lead-follow.ts): the same event, under its follow grant. The
|
|
123
|
+
* caller has already proved the grant and its projects; the root must still be a placed task. */
|
|
124
|
+
export function noteFollowStatus(store, grant, snapshot, now, action = ASSIGNMENT_STATUS_ACTION) {
|
|
125
|
+
if (snapshot.repo === null)
|
|
126
|
+
return null;
|
|
127
|
+
return store.transact(() => {
|
|
128
|
+
const ref = store.lookupRef(snapshot.rootId);
|
|
129
|
+
if (!ref || ref.repo !== snapshot.repo || ref.revisionOf !== null)
|
|
130
|
+
return null;
|
|
131
|
+
return appendStatusEvent(store, followActor(grant), followActor(grant), ref.id, snapshot, now, action);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/** Each reader's latest event or baseline, not a permanent digest set, deduplicates its retries, so Working → Needs
|
|
135
|
+
* decision → Working is retained. One reader's events never suppress or duplicate another's. */
|
|
136
|
+
function appendStatusEvent(store, actor, ownerId, rootRef, snapshot, now, action = ASSIGNMENT_STATUS_ACTION) {
|
|
137
|
+
const assignment = assignmentStatusBrief(snapshot);
|
|
138
|
+
const stateDigest = hash(assignment);
|
|
139
|
+
const previous = store.handle.prepare("SELECT id,outcome FROM action_ledger WHERE task_id=? AND action IN (?,?) AND source=? AND actor=? ORDER BY id DESC LIMIT 1")
|
|
140
|
+
.get(snapshot.rootId, ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, ASSIGNMENT_STATUS_SOURCE, actor);
|
|
141
|
+
if (previous !== undefined) {
|
|
142
|
+
try {
|
|
143
|
+
if (JSON.parse(String(previous["outcome"]))?.stateDigest === stateDigest)
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
catch { /* Preserve an unreadable older event; append the current observation. */ }
|
|
147
|
+
}
|
|
148
|
+
const digest = hash({ rootRef, owner: ownerId, previous: previous === undefined ? null : Number(previous["id"]), stateDigest });
|
|
149
|
+
const event = { version: 1, digest, stateDigest, assignment };
|
|
150
|
+
return store.recordAction({ at: now.toISOString(), actor, repo: snapshot.repo,
|
|
151
|
+
taskId: snapshot.rootId, runId: assignment.runId, action,
|
|
152
|
+
outcome: JSON.stringify(event), source: ASSIGNMENT_STATUS_SOURCE });
|
|
153
|
+
}
|
|
126
154
|
/** At most fifty roots per pass; the cursor survives process restarts. A full
|
|
127
|
-
* pass cycles back, so changes below the cursor are eventually observed too.
|
|
155
|
+
* pass cycles back, so changes below the cursor are eventually observed too.
|
|
156
|
+
* A pass is skipped when nothing moved since the cycle began (sweep-gate.ts). */
|
|
128
157
|
export function syncAssignmentStatuses(store, now, repos, root) {
|
|
129
158
|
if (repos.length === 0)
|
|
130
159
|
return;
|
|
131
160
|
const allowed = [...new Set(repos)].sort();
|
|
132
161
|
const key = `assignment-status:${hash(allowed)}`;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
(SELECT 1 FROM
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
sweepIfMoved(store, key, now, () => {
|
|
163
|
+
const rows = store.handle.prepare(`SELECT t.id,t.external_id FROM task_ref t
|
|
164
|
+
WHERE t.backend='built-in' AND t.revision_of IS NULL
|
|
165
|
+
AND t.repo IN (SELECT value FROM json_each(?)) AND t.id>?
|
|
166
|
+
AND (t.coordinator_cid IS NOT NULL OR EXISTS(SELECT 1 FROM team_task_owner o WHERE o.task_ref=t.id) OR EXISTS
|
|
167
|
+
(SELECT 1 FROM action_ledger a WHERE a.task_id=t.external_id AND a.action='assignment claimed' AND a.source='work'))
|
|
168
|
+
ORDER BY t.id LIMIT 50`).all(JSON.stringify(allowed), store.serviceCursor(key));
|
|
169
|
+
// Release the writer between families. Each observation and its durable
|
|
170
|
+
// position still commit together; an interrupted sweep resumes after the
|
|
171
|
+
// last committed family instead of holding every heartbeat behind a batch.
|
|
172
|
+
if (rows.length === 0)
|
|
173
|
+
store.setServiceCursor(key, 0, now);
|
|
174
|
+
for (const [index, row] of rows.entries()) {
|
|
175
|
+
store.transact(() => {
|
|
176
|
+
const snapshot = assignmentOf(store, String(row['external_id']), now, { principal: 'coordinator', repos: allowed }, root);
|
|
177
|
+
if (snapshot !== null)
|
|
178
|
+
noteAssignmentStatus(store, snapshot, now);
|
|
179
|
+
store.setServiceCursor(key, index === rows.length - 1 && rows.length < 50 ? 0 : Number(row['id']), now);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
152
183
|
}
|
package/dist/assignment.d.ts
CHANGED
|
@@ -233,9 +233,13 @@ export declare function acceptAndCompleteAsOperator(store: Store, taskId: string
|
|
|
233
233
|
receiptDigest: string;
|
|
234
234
|
note: string | null;
|
|
235
235
|
}, who: VerifiedApprover, now: Date, root?: string, finish?: (digest: string) => Finished): Finished;
|
|
236
|
-
/** The ordinary worker reconciles at most 50 owned roots per pass
|
|
237
|
-
*
|
|
236
|
+
/** The ordinary worker reconciles at most 50 owned roots per pass, and skips a
|
|
237
|
+
* pass when nothing moved (sweep-gate.ts). A lost wakeup is recovered after
|
|
238
|
+
* restart; immutable notification keys dedupe it. */
|
|
238
239
|
export declare function syncAssignmentHandoffs(store: Store, now: Date, repos: readonly string[], root?: string): void;
|
|
240
|
+
/** The history of assignment handoffs sent to people (the `assignment-handoff` notifications), read by cursor. It is
|
|
241
|
+
* not a delivery path: nothing is saved, replayed or acknowledged here. Leads receive their updates through the
|
|
242
|
+
* assignment inbox (assignment-delivery.ts), the one road results take to a lead. */
|
|
239
243
|
export declare function assignmentUpdates(store: Store, now: Date, access: AssignmentAccess, query: {
|
|
240
244
|
after: number;
|
|
241
245
|
limit: number;
|
package/dist/assignment.js
CHANGED
|
@@ -9,7 +9,8 @@ import { homedir } from "node:os";
|
|
|
9
9
|
import { readSchemaVersion, stopFactOf } from "./store.js";
|
|
10
10
|
import { approvalOf } from "./scope.js";
|
|
11
11
|
import { openWorkDecisionOf, taskWorkSummaryOf, workDecisionAction } from "./work-summary.js";
|
|
12
|
-
import { evidenceRoot, readVerifiedArtifact, readVerifiedReport } from "./evidence.js";
|
|
12
|
+
import { evidenceRoot, readVerifiedArtifact, readVerifiedReport, verifyArtifact } from "./evidence.js";
|
|
13
|
+
import { sweepIfMoved } from "./sweep-gate.js";
|
|
13
14
|
import { verificationEvidence } from "./verification-evidence.js";
|
|
14
15
|
import { reproveApprover } from "./principal.js";
|
|
15
16
|
import { noteAssignmentStatus } from "./assignment-status.js";
|
|
@@ -163,7 +164,7 @@ export function assignmentOf(store, taskId, now, access, root, memo) {
|
|
|
163
164
|
});
|
|
164
165
|
const artifacts = result === null ? [] : store.artifactsFor(result.id);
|
|
165
166
|
const reports = artifacts.filter(a => a.kind === "report");
|
|
166
|
-
const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !
|
|
167
|
+
const unavailable = family.versions.flatMap(version => store.runsFor(version.refId).flatMap(run => store.artifactsFor(run.id).filter(artifact => root === undefined || !verifyArtifact(root, artifact).ok)
|
|
167
168
|
.map(artifact => `Saved ${artifact.kind} #${artifact.id} (run ${run.id}) is unavailable or changed.`)));
|
|
168
169
|
const ownChecks = result === null ? null : assignmentChecksForRun(store, root, result.id, now);
|
|
169
170
|
// Checks Off: a passing full release check that contains this commit covers it, read now from the existing receipts.
|
|
@@ -418,7 +419,7 @@ export function assignmentEvidenceIntact(store, root, receipt) {
|
|
|
418
419
|
try {
|
|
419
420
|
const family = store.taskFamilyOf(receipt.taskId, null, true);
|
|
420
421
|
if (family === null || family.problem !== null || family.root.id !== receipt.rootId ||
|
|
421
|
-
!family.versions.every(version => store.runsFor(version.refId).every(run => store.artifactsFor(run.id).every(artifact =>
|
|
422
|
+
!family.versions.every(version => store.runsFor(version.refId).every(run => store.artifactsFor(run.id).every(artifact => verifyArtifact(root, artifact).ok))))
|
|
422
423
|
return false;
|
|
423
424
|
if (receipt.completionKind === "research-report") {
|
|
424
425
|
const report = readVerifiedReport(store, root, store.lookupRef(receipt.taskId).id);
|
|
@@ -567,13 +568,14 @@ function noteAssignmentHandoff(store, assignment, now) {
|
|
|
567
568
|
kind: "assignment-handoff", source: { taskRef: ref.id }, subject: assignment.state === "cancelled" ? "Assignment cancelled" : assignment.state === "ready-to-check" ? "Ready" : "Assignment needs a decision",
|
|
568
569
|
body: assignment.detail, link: `/t/${encodeURIComponent(assignment.rootId)}` }, now);
|
|
569
570
|
}
|
|
570
|
-
/** The ordinary worker reconciles at most 50 owned roots per pass
|
|
571
|
-
*
|
|
571
|
+
/** The ordinary worker reconciles at most 50 owned roots per pass, and skips a
|
|
572
|
+
* pass when nothing moved (sweep-gate.ts). A lost wakeup is recovered after
|
|
573
|
+
* restart; immutable notification keys dedupe it. */
|
|
572
574
|
export function syncAssignmentHandoffs(store, now, repos, root) {
|
|
573
575
|
if (repos.length === 0)
|
|
574
576
|
return;
|
|
575
|
-
|
|
576
|
-
|
|
577
|
+
const key = `assignment-handoff:${digest([...new Set(repos)].sort())}`;
|
|
578
|
+
sweepIfMoved(store, key, now, () => store.transact(() => {
|
|
577
579
|
const cursor = store.serviceCursor(key);
|
|
578
580
|
const rows = store.handle.prepare(`SELECT t.id,t.external_id FROM task_ref t WHERE t.backend = 'built-in' AND t.revision_of IS NULL
|
|
579
581
|
AND t.repo IN (SELECT value FROM json_each(?)) AND t.id > ?
|
|
@@ -587,8 +589,11 @@ export function syncAssignmentHandoffs(store, now, repos, root) {
|
|
|
587
589
|
}
|
|
588
590
|
}
|
|
589
591
|
store.setServiceCursor(key, rows.length < 50 ? 0 : Number(rows.at(-1)["id"]), now);
|
|
590
|
-
});
|
|
592
|
+
}));
|
|
591
593
|
}
|
|
594
|
+
/** The history of assignment handoffs sent to people (the `assignment-handoff` notifications), read by cursor. It is
|
|
595
|
+
* not a delivery path: nothing is saved, replayed or acknowledged here. Leads receive their updates through the
|
|
596
|
+
* assignment inbox (assignment-delivery.ts), the one road results take to a lead. */
|
|
592
597
|
export function assignmentUpdates(store, now, access, query, root) {
|
|
593
598
|
const after = Number.isSafeInteger(query.after) && query.after >= 0 ? query.after : 0;
|
|
594
599
|
const limit = Math.max(1, Math.min(100, Math.floor(query.limit) || 50));
|
package/dist/bin.js
CHANGED
|
@@ -17,6 +17,15 @@ if (major < 22 || (major === 22 && minor < 13)) {
|
|
|
17
17
|
process.stderr.write(`Toolroll needs Node 22.13 or newer (this is ${versions.node}) — it uses node:sqlite.\n`);
|
|
18
18
|
process.exit(2);
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// --version and the top-level usage answer here, before the CLI and its command families load.
|
|
21
|
+
const argv = process.argv.slice(2);
|
|
22
|
+
const { quickAnswer } = await import("./cli-fast.js");
|
|
23
|
+
const quick = quickAnswer(argv);
|
|
24
|
+
if (quick !== null) {
|
|
25
|
+
console.log(quick);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const { runCliProcess } = await import("./cli.js");
|
|
29
|
+
await runCliProcess(argv);
|
|
30
|
+
}
|
|
22
31
|
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One line, prefixed, with nothing that can end the block or start a new rule.
|
|
3
|
+
*
|
|
4
|
+
* Scope text is written by whoever filed the task. A goal containing a newline
|
|
5
|
+
* and a bullet would otherwise read to the agent as another rule in the list,
|
|
6
|
+
* which is how "add a guard" becomes "add a guard, and ignore the rules below".
|
|
7
|
+
* The same quoting wraps a steering note the agent reads from its mailbox.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fence(text: string): string;
|