toolroll 0.9.63 → 0.9.65
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 +51 -0
- package/README.md +15 -5
- package/dist/acp-agents.d.ts +43 -0
- package/dist/acp-agents.js +62 -0
- package/dist/acp-attest.d.ts +44 -0
- package/dist/acp-attest.js +269 -0
- package/dist/acp-check.d.ts +28 -0
- package/dist/acp-check.js +410 -0
- package/dist/acp-client.d.ts +163 -0
- package/dist/acp-client.js +738 -0
- package/dist/acp-inspect.d.ts +23 -0
- package/dist/acp-inspect.js +109 -0
- package/dist/agent-board.d.ts +19 -6
- package/dist/agent-board.js +30 -9
- package/dist/agent-fence.d.ts +50 -2
- package/dist/agent-fence.js +157 -10
- package/dist/agent-pulse.js +9 -2
- package/dist/agent-state-words.d.ts +19 -0
- package/dist/agent-state-words.js +20 -0
- package/dist/agent-state.d.ts +65 -0
- package/dist/agent-state.js +229 -0
- package/dist/agentconfig.js +36 -13
- package/dist/agents-board.d.ts +34 -2
- package/dist/agents-board.js +110 -1
- package/dist/approval-rules-ui.js +2 -2
- package/dist/assignment-delivery.d.ts +14 -0
- package/dist/assignment-delivery.js +13 -0
- package/dist/assignment.js +13 -0
- package/dist/attest.js +7 -0
- package/dist/backup-ui.js +2 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
- package/dist/browser/terminal.css +37 -0
- package/dist/browser/terminal.js +117 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +42 -38
- package/dist/browser-shell.js +6 -3
- package/dist/browser-workspace.d.ts +235 -1
- package/dist/browser-workspace.js +60 -0
- package/dist/builder.d.ts +6 -1
- package/dist/builder.js +34 -13
- package/dist/chat-actions.d.ts +13 -1
- package/dist/chat-actions.js +197 -15
- package/dist/chat-ask.d.ts +4 -0
- package/dist/chat-ask.js +14 -0
- package/dist/chat-channel.js +20 -0
- package/dist/chat-continuity.js +5 -4
- package/dist/chat-decide.d.ts +0 -14
- package/dist/chat-decide.js +4 -1
- package/dist/chat-delivery.d.ts +9 -0
- package/dist/chat-delivery.js +45 -6
- package/dist/chat-messages.d.ts +8 -0
- package/dist/chat-messages.js +36 -4
- package/dist/check-levels-ui.js +2 -2
- package/dist/checkout-proof.d.ts +28 -0
- package/dist/checkout-proof.js +62 -0
- package/dist/claim.d.ts +12 -0
- package/dist/claim.js +19 -0
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +4 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +21 -2
- package/dist/coding-ui.js +6 -4
- package/dist/console-instance.d.ts +124 -0
- package/dist/console-instance.js +337 -0
- package/dist/contracts/chat-actions.d.ts +16 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/chat-content.d.ts +19 -0
- package/dist/contracts/chat-content.js +6 -0
- package/dist/contracts/console-api.d.ts +12 -4
- package/dist/contracts/console-api.js +12 -4
- package/dist/contracts/flow-card.d.ts +4 -0
- package/dist/contracts/flow-card.js +3 -0
- package/dist/contracts/flow.d.ts +46 -0
- package/dist/contracts/flow.js +11 -2
- package/dist/contracts/lead-tools.d.ts +92 -0
- package/dist/contracts/lead-tools.js +13 -0
- package/dist/contracts/provider.d.ts +11 -1
- package/dist/contracts/provider.js +1 -1
- package/dist/contracts/route.d.ts +76 -1
- package/dist/contracts/route.js +1 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/control-setup.d.ts +1 -1
- package/dist/control-ui.js +2 -2
- package/dist/converse.d.ts +16 -0
- package/dist/converse.js +32 -4
- package/dist/demo-chat.js +4 -4
- package/dist/demo.js +2 -2
- package/dist/desktop-update-gate.js +6 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +4 -1
- package/dist/doctor.js +1 -1
- package/dist/egress-bridge.d.ts +17 -0
- package/dist/egress-bridge.js +111 -0
- package/dist/egress.d.ts +119 -0
- package/dist/egress.js +423 -0
- package/dist/exec.d.ts +78 -0
- package/dist/exec.js +367 -9
- package/dist/export-ui.js +2 -2
- package/dist/flow-engine.js +24 -5
- package/dist/flow-gallery-ui.js +2 -2
- package/dist/flow-gallery.js +27 -8
- package/dist/flow-share.js +16 -5
- package/dist/flow-triggers.d.ts +77 -8
- package/dist/flow-triggers.js +517 -70
- package/dist/flows-cli.js +8 -2
- package/dist/flows-ui.d.ts +0 -2
- package/dist/flows-ui.js +52 -4
- package/dist/flows.d.ts +4 -0
- package/dist/flows.js +9 -1
- package/dist/html.d.ts +9 -0
- package/dist/html.js +11 -0
- package/dist/invoke.d.ts +17 -0
- package/dist/invoke.js +217 -21
- package/dist/keys.d.ts +18 -14
- package/dist/keys.js +5 -0
- package/dist/lead-capabilities.js +3 -2
- package/dist/lead-contract.js +2 -0
- package/dist/lead-doors.d.ts +2 -0
- package/dist/lead-doors.js +7 -0
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +58 -2
- package/dist/lead-progress.js +1 -0
- package/dist/lead-queue-runtime.d.ts +46 -0
- package/dist/lead-queue-runtime.js +191 -0
- package/dist/lead-queue.d.ts +104 -0
- package/dist/lead-queue.js +187 -0
- package/dist/lead-sweep.d.ts +67 -0
- package/dist/lead-sweep.js +99 -0
- package/dist/lead-tools.d.ts +22 -0
- package/dist/lead-tools.js +152 -1
- package/dist/lead.d.ts +13 -5
- package/dist/lead.js +89 -29
- package/dist/limits-ui.d.ts +8 -1
- package/dist/limits-ui.js +26 -0
- package/dist/live-bus.d.ts +8 -0
- package/dist/live-bus.js +40 -4
- package/dist/live-detail.d.ts +27 -0
- package/dist/live-detail.js +57 -0
- package/dist/live-notes.d.ts +20 -0
- package/dist/live-notes.js +47 -0
- package/dist/live-records.d.ts +140 -0
- package/dist/live-records.js +935 -0
- package/dist/live-terminal.d.ts +258 -0
- package/dist/live-terminal.js +649 -0
- package/dist/live.d.ts +38 -27
- package/dist/live.js +142 -124
- package/dist/mcp-oauth.d.ts +6 -3
- package/dist/mcp-oauth.js +8 -4
- package/dist/memory-pass.js +2 -2
- package/dist/mobile-viewport.d.ts +5 -1
- package/dist/mobile-viewport.js +18 -2
- package/dist/model-catalog.js +4 -0
- package/dist/monitoring-ui.js +2 -2
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +11 -4
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +275 -17
- package/dist/passwordless.d.ts +23 -0
- package/dist/passwordless.js +26 -0
- package/dist/peek-cli.d.ts +12 -4
- package/dist/peek-cli.js +19 -11
- package/dist/phase-routing.js +10 -3
- package/dist/planner-source.d.ts +3 -0
- package/dist/planner-source.js +14 -0
- package/dist/planner.d.ts +2 -1
- package/dist/planner.js +7 -1
- package/dist/policy-ui.js +2 -2
- package/dist/policy.d.ts +1 -20
- package/dist/policy.js +29 -2
- package/dist/project-delete-ui.d.ts +12 -0
- package/dist/project-delete-ui.js +41 -9
- package/dist/project-delete.js +2 -2
- package/dist/project-memory.d.ts +3 -1
- package/dist/project-memory.js +57 -25
- package/dist/project-tools.d.ts +5 -3
- package/dist/project-tools.js +12 -9
- package/dist/provider-auth.d.ts +4 -3
- package/dist/provider-auth.js +5 -4
- package/dist/provider-connection.d.ts +2 -1
- package/dist/provider-connection.js +5 -0
- package/dist/provider.d.ts +25 -2
- package/dist/provider.js +63 -9
- package/dist/pull-request-ui.js +2 -2
- package/dist/push.js +1 -1
- package/dist/quick-start.d.ts +24 -0
- package/dist/quick-start.js +127 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +14 -8
- package/dist/request-budget-ui.js +2 -2
- package/dist/result-review.d.ts +1 -1
- package/dist/result-review.js +9 -3
- package/dist/retention-ui.js +2 -2
- package/dist/retention.js +10 -1
- package/dist/run-efficiency.d.ts +73 -0
- package/dist/run-efficiency.js +228 -0
- package/dist/runner.d.ts +5 -1
- package/dist/runner.js +22 -1
- package/dist/scope.d.ts +170 -5
- package/dist/scope.js +251 -31
- package/dist/scout-net.d.ts +4 -1
- package/dist/scout-net.js +37 -12
- package/dist/scout.d.ts +20 -6
- package/dist/scout.js +27 -3
- package/dist/serve.js +247 -40
- package/dist/server/chat.d.ts +1 -1
- package/dist/server/chat.js +122 -31
- package/dist/server/chrome.d.ts +6 -1
- package/dist/server/chrome.js +18 -7
- package/dist/server/console.css +52 -38
- package/dist/server/flows.js +38 -8
- package/dist/server/guards.d.ts +1 -0
- package/dist/server/guards.js +23 -9
- package/dist/server/http.d.ts +7 -0
- package/dist/server/http.js +3 -2
- package/dist/server/live.d.ts +34 -1
- package/dist/server/live.js +39 -17
- package/dist/server/pages-assets.js +5 -0
- package/dist/server/pages.js +90 -13
- package/dist/server/people-tokens.d.ts +3 -0
- package/dist/server/people-tokens.js +55 -17
- package/dist/server/remote-hooks.js +12 -6
- package/dist/server/remote.d.ts +2 -1
- package/dist/server/remote.js +14 -5
- package/dist/server/render-chat.d.ts +12 -3
- package/dist/server/render-chat.js +47 -16
- package/dist/server/render-pages.d.ts +4 -1
- package/dist/server/render-pages.js +5 -4
- package/dist/server/render-people.d.ts +3 -2
- package/dist/server/render-people.js +3 -2
- package/dist/server/render-results.d.ts +18 -2
- package/dist/server/render-results.js +37 -28
- package/dist/server/render-settings.js +6 -5
- package/dist/server/render-tasks.d.ts +35 -10
- package/dist/server/render-tasks.js +110 -27
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/request-context.js +2 -1
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +46 -5
- package/dist/server/runtime.d.ts +33 -2
- package/dist/server/session.d.ts +25 -1
- package/dist/server/session.js +18 -4
- package/dist/server/settings.js +121 -10
- package/dist/server/sweeps.d.ts +4 -0
- package/dist/server/sweeps.js +58 -0
- package/dist/server/task-views.d.ts +2 -1
- package/dist/server/task-views.js +96 -6
- package/dist/server/tasks.js +320 -74
- package/dist/server/terminals.d.ts +31 -0
- package/dist/server/terminals.js +365 -0
- package/dist/setup-guide.js +3 -2
- package/dist/slack-chat.js +4 -1
- package/dist/spend-ui.js +3 -3
- package/dist/spend.js +2 -1
- package/dist/steer-capabilities.d.ts +32 -0
- package/dist/steer-capabilities.js +60 -0
- package/dist/steer-controls.d.ts +14 -0
- package/dist/steer-controls.js +36 -0
- package/dist/steer-status.js +2 -2
- package/dist/storage-ui.js +4 -4
- package/dist/store-baseline.d.ts +1 -1
- package/dist/store-baseline.js +1 -1
- package/dist/store.d.ts +430 -15
- package/dist/store.js +1041 -52
- package/dist/subagent-desk.js +1 -1
- package/dist/summary.js +3 -0
- package/dist/task-source.d.ts +58 -0
- package/dist/task-source.js +93 -0
- package/dist/team-leads.js +1 -1
- package/dist/teams-chat.js +4 -1
- package/dist/telegram-lead.d.ts +3 -0
- package/dist/telegram-lead.js +44 -7
- package/dist/telegram.d.ts +11 -2
- package/dist/telegram.js +51 -17
- package/dist/terminal-catalog.d.ts +11 -0
- package/dist/terminal-catalog.js +129 -0
- package/dist/text-limits.d.ts +2 -0
- package/dist/text-limits.js +2 -0
- package/dist/tool-broker.d.ts +2 -0
- package/dist/tool-broker.js +1 -0
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +13 -3
- package/dist/tools-ui.js +2 -2
- package/dist/webhook-limits.d.ts +23 -0
- package/dist/webhook-limits.js +30 -0
- package/dist/webhook-readers.d.ts +56 -0
- package/dist/webhook-readers.js +138 -0
- package/dist/webhook-signing.d.ts +27 -0
- package/dist/webhook-signing.js +119 -0
- package/dist/work-index.d.ts +29 -0
- package/dist/work-index.js +16 -2
- package/dist/workspace-revision.js +3 -1
- package/package.json +11 -2
package/dist/lead-follow.js
CHANGED
|
@@ -9,7 +9,8 @@ import { LEAD_FOLLOW_MESSAGE } from './store.js';
|
|
|
9
9
|
import { isVerifiedApprover, reproveApprover, verifyApproverStanding } from './principal.js';
|
|
10
10
|
import { assignmentOf } from './assignment.js';
|
|
11
11
|
import { ASSIGNMENT_STATUS_ACTION, ASSIGNMENT_STATUS_BASELINE, followActor, LEAD_WAKE_STATES, noteFollowStatus } from './assignment-status.js';
|
|
12
|
-
import { acknowledgeLeadFollowDelivery, leadFollowInbox } from './assignment-delivery.js';
|
|
12
|
+
import { acknowledgeLeadFollowDelivery, leadFollowInbox, leadFollowUnread } from './assignment-delivery.js';
|
|
13
|
+
import { sweepLine, sweepTable } from './lead-sweep.js';
|
|
13
14
|
import { publicChatText } from './chat-display.js';
|
|
14
15
|
import { runLeadTurn } from './lead.js';
|
|
15
16
|
import { updateAdmissionPaused } from './desktop-update-gate.js';
|
|
@@ -167,6 +168,56 @@ export async function runLeadFollowPass(input) {
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
}
|
|
171
|
+
/** How long a burst of one sweep's updates is held so it lands on one page (docs/plans/lead-fan-out.md). */
|
|
172
|
+
export const SWEEP_SETTLE_MS = 2 * 60_000;
|
|
173
|
+
/**
|
|
174
|
+
* Whether to hold this pass's page for a sweep: every unread event is a sweep child's, a sweep among them still has
|
|
175
|
+
* children working, and the hold's deadline (saved with the first held pass, so it never resets) hasn't passed. An
|
|
176
|
+
* issued page, any non-sweep event, or a sweep fully settled issues at once. Never sleeps: the next scheduled pass
|
|
177
|
+
* looks again.
|
|
178
|
+
*/
|
|
179
|
+
function holdForSweeps(store, grant, now) {
|
|
180
|
+
const key = `lead:${grant.id}:sweep-hold`;
|
|
181
|
+
const release = () => { if (store.serviceCursor(key) !== 0)
|
|
182
|
+
store.setServiceCursor(key, 0, now); return false; };
|
|
183
|
+
const unread = leadFollowUnread(store, grant, CONSUMER);
|
|
184
|
+
// Only what would wake the lead holds a page; updates it reads silently (work started) go straight through.
|
|
185
|
+
const waking = unread.events.filter(one => LEAD_WAKE_STATES.includes(one.state));
|
|
186
|
+
if (unread.issued || waking.length === 0)
|
|
187
|
+
return release();
|
|
188
|
+
const sweeps = new Set();
|
|
189
|
+
for (const { task } of waking) {
|
|
190
|
+
const sweep = store.sweepOfTask(task);
|
|
191
|
+
if (sweep === null)
|
|
192
|
+
return release();
|
|
193
|
+
sweeps.add(sweep.id);
|
|
194
|
+
}
|
|
195
|
+
const working = [...sweeps].some(id => { const table = sweepTable(store, id, now, grant.repos); return table !== null && table.counts.working > 0; });
|
|
196
|
+
if (!working)
|
|
197
|
+
return release();
|
|
198
|
+
const until = store.serviceCursor(key);
|
|
199
|
+
if (until === 0) {
|
|
200
|
+
store.setServiceCursor(key, now.getTime() + SWEEP_SETTLE_MS, now);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return now.getTime() < until ? true : release();
|
|
204
|
+
}
|
|
205
|
+
/** A page's updates for sweep children, grouped: one line per sweep with its table, so the lead says one line each. */
|
|
206
|
+
function sweepGroups(store, grant, updates, now) {
|
|
207
|
+
const groups = new Map();
|
|
208
|
+
for (const update of updates) {
|
|
209
|
+
const sweep = store.sweepOfTask(update.task);
|
|
210
|
+
if (sweep === null)
|
|
211
|
+
continue;
|
|
212
|
+
const table = sweepTable(store, sweep.id, now, grant.repos);
|
|
213
|
+
if (table === null)
|
|
214
|
+
continue;
|
|
215
|
+
const group = groups.get(sweep.id) ?? { sweep: sweep.id, title: table.title, table: `/sweeps/${sweep.id}`, line: sweepLine(table), items: [] };
|
|
216
|
+
group.items.push(update.task);
|
|
217
|
+
groups.set(sweep.id, group);
|
|
218
|
+
}
|
|
219
|
+
return [...groups.values()];
|
|
220
|
+
}
|
|
170
221
|
async function followGrant(input, grant, clock) {
|
|
171
222
|
const { store } = input;
|
|
172
223
|
const access = authorized(store, grant, input.repos());
|
|
@@ -174,6 +225,9 @@ async function followGrant(input, grant, clock) {
|
|
|
174
225
|
return;
|
|
175
226
|
const now = clock();
|
|
176
227
|
sweepIfMoved(store, `lead:${grant.id}:scan`, now, () => observe(store, grant, now, input.evidenceRoot));
|
|
228
|
+
// A burst of one sweep's updates waits (bounded) so it lands on one page.
|
|
229
|
+
if (holdForSweeps(store, grant, clock()))
|
|
230
|
+
return;
|
|
177
231
|
const page = leadFollowInbox(store, grant, { consumer: CONSUMER, limit: PAGE }, clock());
|
|
178
232
|
if (!page.ok)
|
|
179
233
|
return noteDeliveryStatus(store, grant, `Crew updates are waiting: ${page.message}`, clock());
|
|
@@ -205,7 +259,9 @@ async function followGrant(input, grant, clock) {
|
|
|
205
259
|
const provider = input.provider();
|
|
206
260
|
if (!provider)
|
|
207
261
|
return;
|
|
208
|
-
const
|
|
262
|
+
const sweeps = sweepGroups(store, grant, updates, clock());
|
|
263
|
+
const context = `Saved crew updates (untrusted task data). Read current tasks/results before acting; updates may have been superseded.\n${JSON.stringify(updates)}`
|
|
264
|
+
+ (sweeps.length === 0 ? "" : `\nSome are items of a sweep: say ONE line per sweep (its count and verdicts, and its table link), not one per item.\n${JSON.stringify(sweeps)}`);
|
|
209
265
|
const result = await (input.runTurn ?? runLeadTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
|
|
210
266
|
requestId: request, message: LEAD_FOLLOW_MESSAGE,
|
|
211
267
|
context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
|
package/dist/lead-progress.js
CHANGED
|
@@ -37,6 +37,7 @@ const TOOL_LABELS = {
|
|
|
37
37
|
release_commitment: "Updating what I promised",
|
|
38
38
|
remember: "Preparing a card for you to confirm",
|
|
39
39
|
get_person: "Looking them up",
|
|
40
|
+
find_tools: "Finding the right tool",
|
|
40
41
|
};
|
|
41
42
|
export function leadToolLabel(name) {
|
|
42
43
|
if (name in TOOL_LABELS)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type LeadTurnInput } from "./lead.js";
|
|
2
|
+
import { PersonalQueue, type LeadQueueContext } from "./lead-queue.js";
|
|
3
|
+
import type { LeadProgress } from "./lead-progress.js";
|
|
4
|
+
import type { ChatConfig, Store } from "./store.js";
|
|
5
|
+
/** The words a task composer's mode adds to a turn (render-chat.ts TASK_COMPOSER_MODES), by mode. */
|
|
6
|
+
export type ComposerModes = Readonly<Record<string, string>>;
|
|
7
|
+
/** The context a lead turn is given for what the person was looking at: the same words for a message sent now and one
|
|
8
|
+
* that waited. Null when the message was sent from the lead's own chat. */
|
|
9
|
+
export declare function leadTurnContext(store: Store, context: LeadQueueContext | null, names: {
|
|
10
|
+
project: (path: string) => string;
|
|
11
|
+
modes: ComposerModes;
|
|
12
|
+
}): string | undefined;
|
|
13
|
+
export type PersonalQueueRuntimeOptions = {
|
|
14
|
+
store: Store;
|
|
15
|
+
evidenceRoot: string;
|
|
16
|
+
/** The chat provider now (null when chat is off: messages wait). */
|
|
17
|
+
provider: () => {
|
|
18
|
+
config: ChatConfig;
|
|
19
|
+
key: string | null;
|
|
20
|
+
} | null;
|
|
21
|
+
/** The projects the console admits for this person, computed as that person (never from a live request). */
|
|
22
|
+
admittedReposFor: (account: string) => string[];
|
|
23
|
+
/** Whether the task (or result run) a message was sent about is still there for this person. */
|
|
24
|
+
contextStands: (account: string, context: LeadQueueContext) => boolean;
|
|
25
|
+
names: {
|
|
26
|
+
project: (path: string) => string;
|
|
27
|
+
modes: ComposerModes;
|
|
28
|
+
};
|
|
29
|
+
/** The console's live reply display: started per turn, ended with the progress handle it returned. */
|
|
30
|
+
live?: {
|
|
31
|
+
begin: (thread: number) => (event: LeadProgress) => void;
|
|
32
|
+
end: (thread: number, ok: boolean, progress: (event: LeadProgress) => void) => void;
|
|
33
|
+
};
|
|
34
|
+
subscriptionRunner?: LeadTurnInput["subscriptionRunner"];
|
|
35
|
+
fetcher?: typeof fetch;
|
|
36
|
+
clock?: () => Date;
|
|
37
|
+
everyMs?: number;
|
|
38
|
+
};
|
|
39
|
+
export declare function createPersonalQueueRuntime(options: PersonalQueueRuntimeOptions): {
|
|
40
|
+
queue: PersonalQueue;
|
|
41
|
+
/** Look now (a turn just ended, a message was just queued): the next message of each person may start. Safe to call
|
|
42
|
+
* from a request: the pass runs in the server's own context, never the caller's. Before start, the timer will look. */
|
|
43
|
+
kick(): void;
|
|
44
|
+
start(): void;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the personal lead queue (docs/plans/lead-message-queue.md): one message per turn, in order, through the engine's
|
|
3
|
+
* own admission. Each claim is re-proved from its row — the person's standing at the generation it was sent under, the
|
|
4
|
+
* session it was sent under (never a newer one), the project set it was sent under, its task and result still there —
|
|
5
|
+
* and re-checked before every model step. A refusal before admission waits with its words and wakes when what refused
|
|
6
|
+
* it changes; anything after admission is settled from the turn's own record and never sent again.
|
|
7
|
+
*/
|
|
8
|
+
import { AsyncResource } from "node:async_hooks";
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { runLeadTurn } from "./lead.js";
|
|
11
|
+
import { PersonalQueue } from "./lead-queue.js";
|
|
12
|
+
import { verifyApproverStanding } from "./principal.js";
|
|
13
|
+
import { updateAdmissionPaused } from "./desktop-update-gate.js";
|
|
14
|
+
import { TURN_WALL_CLOCK_MS } from "./converse.js";
|
|
15
|
+
/** The context a lead turn is given for what the person was looking at: the same words for a message sent now and one
|
|
16
|
+
* that waited. Null when the message was sent from the lead's own chat. */
|
|
17
|
+
export function leadTurnContext(store, context, names) {
|
|
18
|
+
if (context === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (context.task !== undefined) {
|
|
21
|
+
const viewed = context.result === undefined ? null : store.getRun(context.result);
|
|
22
|
+
const result = viewed === null ? "" : ` The operator is viewing result #${viewed.id} from execution ${store.externalIdFor(viewed.taskRef)}. Use get_result for that exact execution and run when responding to feedback; do not substitute another result.`;
|
|
23
|
+
const mode = context.mode === undefined ? "" : ` ${names.modes[context.mode] ?? ""}`;
|
|
24
|
+
return `Current task: ${context.task}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${result}${mode}`;
|
|
25
|
+
}
|
|
26
|
+
if (context.project !== undefined)
|
|
27
|
+
return `Current project: ${names.project(context.project)} (${context.project}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.`;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Refusals before admission that wait (with their words) rather than end the message: what wakes each one is in its
|
|
31
|
+
* stamp (a turn ends, the day or week or month rolls, the cost is confirmed, a budget is raised). */
|
|
32
|
+
const WAITS = new Set(["concurrent", "backlog", "daily-cap", "over-budget", "monthly-budget", "latched"]);
|
|
33
|
+
const UPDATE_WAIT = "Waiting: Toolroll is updating. It runs once the update is done.";
|
|
34
|
+
export function createPersonalQueueRuntime(options) {
|
|
35
|
+
const { store } = options, clock = options.clock ?? (() => new Date());
|
|
36
|
+
const queue = new PersonalQueue(store);
|
|
37
|
+
const runner = `personal:${process.pid}:${randomUUID()}`;
|
|
38
|
+
/** A deferred message's admission stamp: it is not tried again until something that refused it changes. */
|
|
39
|
+
const waiting = new Map();
|
|
40
|
+
let timer = null, closed = false, passing = false;
|
|
41
|
+
/** The server's own async context, captured at start (no request in it). A wake from inside a browser request runs
|
|
42
|
+
* its pass from here, so no request's session facts (its actor, form token or Passwordless session) ever reach a
|
|
43
|
+
* queued turn — the queue re-proves each message's person itself. */
|
|
44
|
+
let detached = null;
|
|
45
|
+
const active = new Set();
|
|
46
|
+
/** What openLeadTurn checks for this person, and the chat configuration (its caps and budget), with a minute bucket
|
|
47
|
+
* as the belt for what is not named here (weekly and monthly spend roll over within it): a change wakes a waiting message. */
|
|
48
|
+
function stamp(row) {
|
|
49
|
+
const now = clock();
|
|
50
|
+
const live = store.handle.prepare("SELECT id, state FROM mate_turn WHERE approver = ? AND state IN ('queued','running') ORDER BY id").all(row.approver);
|
|
51
|
+
const liveChat = store.handle.prepare("SELECT id, state FROM chat_turn WHERE approver = ? AND state IN ('queued','running') ORDER BY id").all(row.approver);
|
|
52
|
+
const session = store.getLeadSession(row.session);
|
|
53
|
+
const latched = store.handle.prepare("SELECT COUNT(*) AS n FROM chat_turn WHERE approver = ? AND unknown_spend = 1 AND acknowledged_at IS NULL").get(row.approver);
|
|
54
|
+
return createHash("sha256").update(JSON.stringify([live, liveChat, store.chatTurnsToday(row.approver, now), session?.spentMicrousd ?? null,
|
|
55
|
+
session?.ceilingMicrousd ?? null, latched?.["n"] ?? 0, updateAdmissionPaused(store.raw()), options.provider()?.config ?? null, Math.floor(now.getTime() / 60_000)])).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
/** The person as the row was sent: same generation, same session (live, theirs), same projects. Null: it can't run. */
|
|
58
|
+
function standing(row) {
|
|
59
|
+
const session = store.getLeadSession(row.session);
|
|
60
|
+
if (session === null || session.endedAt !== null || session.approver !== row.approver)
|
|
61
|
+
return { ok: false, why: "Not sent: this conversation ended before it ran." };
|
|
62
|
+
const verified = verifyApproverStanding(store, row.approver, row.approverGeneration, options.admittedReposFor(row.approver));
|
|
63
|
+
if (!verified.ok)
|
|
64
|
+
return { ok: false, why: "Not sent: your access changed before it ran." };
|
|
65
|
+
if (verified.who.ceilingDigest !== row.ceilingDigest || session.ceilingDigest !== row.ceilingDigest)
|
|
66
|
+
return { ok: false, why: "Not sent: your projects changed before it ran." };
|
|
67
|
+
const thread = store.getLeadThread(row.thread);
|
|
68
|
+
if (thread === null || thread.closedAt !== null)
|
|
69
|
+
return { ok: false, why: "Not sent: this conversation was closed before it ran." };
|
|
70
|
+
if (row.context !== null && !options.contextStands(row.approver, row.context))
|
|
71
|
+
return { ok: false, why: "Not sent: the task it was about is no longer available." };
|
|
72
|
+
return { ok: true, who: verified.who };
|
|
73
|
+
}
|
|
74
|
+
const verifiedWho = (who) => who;
|
|
75
|
+
async function run(claim) {
|
|
76
|
+
let progress;
|
|
77
|
+
let outcome;
|
|
78
|
+
try {
|
|
79
|
+
const proof = standing(claim);
|
|
80
|
+
if (!proof.ok) {
|
|
81
|
+
queue.finish(claim, { status: "failed", error: proof.why }, clock());
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const provider = options.provider();
|
|
85
|
+
if (provider === null) {
|
|
86
|
+
if (queue.defer(claim, "Waiting: chat is off. It runs once chat is on again."))
|
|
87
|
+
waiting.set(claim.message, stamp(claim));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// An update is being installed (or the console is stopping): the message waits for the next start, words kept.
|
|
91
|
+
const holding = () => closed || updateAdmissionPaused(store.raw());
|
|
92
|
+
if (holding()) {
|
|
93
|
+
if (queue.defer(claim, UPDATE_WAIT))
|
|
94
|
+
waiting.set(claim.message, stamp(claim));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let held = false;
|
|
98
|
+
const session = store.getLeadSession(claim.session), thread = store.getLeadThread(claim.thread);
|
|
99
|
+
const context = leadTurnContext(store, claim.context, options.names);
|
|
100
|
+
outcome = await runLeadTurn({
|
|
101
|
+
store, who: proof.who, session, thread, config: provider.config, key: provider.key, message: claim.text, requestId: claim.requestId,
|
|
102
|
+
queuedMessageId: claim.message, queueClaim: { message: claim.message, runner: claim.runner, generation: claim.generation },
|
|
103
|
+
// The live reply display starts only once this message holds the turn: a refused attempt never blanks the display
|
|
104
|
+
// of a turn that is still running in the same conversation.
|
|
105
|
+
onAdmitted: turn => {
|
|
106
|
+
if (!queue.bindTurn(claim, turn))
|
|
107
|
+
throw new Error("The queued message changed before admission.");
|
|
108
|
+
progress = options.live?.begin(claim.thread);
|
|
109
|
+
},
|
|
110
|
+
...(context === undefined ? {} : { context }), channel: "console", clock, evidenceRoot: options.evidenceRoot,
|
|
111
|
+
...(options.live === undefined ? {} : { onProgress: (event) => progress?.(event) }),
|
|
112
|
+
// Before every step: still this claim, still this person at this generation and session, still the same projects.
|
|
113
|
+
revalidate: async () => {
|
|
114
|
+
if (holding()) {
|
|
115
|
+
held = true;
|
|
116
|
+
return { ok: false, reason: "access-changed" };
|
|
117
|
+
}
|
|
118
|
+
return queue.current(claim) && standing(claim).ok ? { ok: true } : { ok: false, reason: "access-changed" };
|
|
119
|
+
},
|
|
120
|
+
...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
|
|
121
|
+
...(options.subscriptionRunner === undefined ? {} : { subscriptionRunner: options.subscriptionRunner }),
|
|
122
|
+
});
|
|
123
|
+
if (outcome.ok) {
|
|
124
|
+
queue.finish(claim, { status: "answered", turnId: outcome.turn }, clock());
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if ("refused" in outcome) {
|
|
128
|
+
// Refused before admission because an update paused it: that is a wait too (the words stay; the pause ending wakes it).
|
|
129
|
+
if (outcome.refused === "channel" && held) {
|
|
130
|
+
if (queue.defer(claim, UPDATE_WAIT))
|
|
131
|
+
waiting.set(claim.message, stamp(claim));
|
|
132
|
+
}
|
|
133
|
+
else if (WAITS.has(outcome.refused)) {
|
|
134
|
+
if (queue.defer(claim, `Waiting: ${outcome.message}`))
|
|
135
|
+
waiting.set(claim.message, stamp(claim));
|
|
136
|
+
}
|
|
137
|
+
else
|
|
138
|
+
queue.finish(claim, { status: "failed", error: `Not sent: ${outcome.message}` }, clock());
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// After admission: settled from the turn's record; an unknown outcome is never sent again.
|
|
142
|
+
queue.finish(claim, { status: "unknownSpend" in outcome && outcome.unknownSpend ? "uncertain" : "failed", error: outcome.message, ...("turn" in outcome ? { turnId: outcome.turn } : {}) }, clock());
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
queue.finish(claim, { status: "uncertain", error: "Delivery could not be confirmed. Nothing was sent again." }, clock());
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (progress !== undefined)
|
|
149
|
+
options.live?.end(claim.thread, outcome?.ok === true, progress);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function pass() {
|
|
153
|
+
if (closed || passing || store.isDemo())
|
|
154
|
+
return;
|
|
155
|
+
passing = true;
|
|
156
|
+
try {
|
|
157
|
+
queue.reconcile(clock(), TURN_WALL_CLOCK_MS + 60_000);
|
|
158
|
+
for (;;) {
|
|
159
|
+
const claim = queue.claimNext(runner, clock(), row => waiting.get(row.message) !== stamp(row));
|
|
160
|
+
if (claim === null)
|
|
161
|
+
break;
|
|
162
|
+
waiting.delete(claim.message);
|
|
163
|
+
const work = run(claim);
|
|
164
|
+
active.add(work);
|
|
165
|
+
void work.finally(() => active.delete(work)).catch(() => undefined);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
passing = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
queue,
|
|
174
|
+
/** Look now (a turn just ended, a message was just queued): the next message of each person may start. Safe to call
|
|
175
|
+
* from a request: the pass runs in the server's own context, never the caller's. Before start, the timer will look. */
|
|
176
|
+
kick() {
|
|
177
|
+
const scope = detached;
|
|
178
|
+
if (scope === null || closed)
|
|
179
|
+
return;
|
|
180
|
+
scope.runInAsyncScope(() => { setImmediate(() => { void pass(); }); });
|
|
181
|
+
},
|
|
182
|
+
start() {
|
|
183
|
+
if (timer !== null || closed)
|
|
184
|
+
return;
|
|
185
|
+
detached = new AsyncResource("lead-queue-runtime");
|
|
186
|
+
detached.runInAsyncScope(() => { timer = setInterval(() => { void pass(); }, options.everyMs ?? 2_000); timer.unref(); });
|
|
187
|
+
},
|
|
188
|
+
async close() { closed = true; if (timer !== null)
|
|
189
|
+
clearInterval(timer); timer = null; await Promise.allSettled([...active]); },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
/** At most this many messages wait per person, and this many characters in all; a waiting message expires after 7 days. */
|
|
3
|
+
export declare const LEAD_QUEUE_MAX_MESSAGES = 10;
|
|
4
|
+
export declare const LEAD_QUEUE_MAX_CHARS = 20000;
|
|
5
|
+
export declare const LEAD_QUEUE_EXPIRY_MS: number;
|
|
6
|
+
/** What a queued message was sent with: resolved and validated at send, re-validated before its turn. */
|
|
7
|
+
export type LeadQueueContext = {
|
|
8
|
+
task?: string;
|
|
9
|
+
result?: number;
|
|
10
|
+
mode?: string;
|
|
11
|
+
project?: string;
|
|
12
|
+
};
|
|
13
|
+
export type LeadQueueStatus = "queued" | "running" | "answered" | "failed" | "cancelled" | "uncertain";
|
|
14
|
+
export type LeadQueueRow = {
|
|
15
|
+
message: number;
|
|
16
|
+
thread: number;
|
|
17
|
+
approver: string;
|
|
18
|
+
approverGeneration: number;
|
|
19
|
+
session: number;
|
|
20
|
+
ceilingDigest: string;
|
|
21
|
+
requestId: string;
|
|
22
|
+
payloadHash: string;
|
|
23
|
+
context: LeadQueueContext | null;
|
|
24
|
+
status: LeadQueueStatus;
|
|
25
|
+
revision: number;
|
|
26
|
+
generation: number;
|
|
27
|
+
runner: string | null;
|
|
28
|
+
turnId: number | null;
|
|
29
|
+
error: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
text: string;
|
|
32
|
+
};
|
|
33
|
+
/** A claimed row: the fence every later step re-proves (status running, this runner, this generation and revision). */
|
|
34
|
+
export type LeadQueueClaim = LeadQueueRow & {
|
|
35
|
+
runner: string;
|
|
36
|
+
status: "running";
|
|
37
|
+
};
|
|
38
|
+
/** The identity of a send: the same request with the same words and context is the same message. */
|
|
39
|
+
export declare function leadQueuePayloadHash(thread: number, text: string, context: LeadQueueContext | null): string;
|
|
40
|
+
export type SendDecision = {
|
|
41
|
+
kind: "run";
|
|
42
|
+
} | {
|
|
43
|
+
kind: "queued";
|
|
44
|
+
row: LeadQueueRow;
|
|
45
|
+
already: boolean;
|
|
46
|
+
} | {
|
|
47
|
+
kind: "conflict";
|
|
48
|
+
} | {
|
|
49
|
+
kind: "full";
|
|
50
|
+
message: string;
|
|
51
|
+
};
|
|
52
|
+
export declare class PersonalQueue {
|
|
53
|
+
private readonly store;
|
|
54
|
+
constructor(store: Store);
|
|
55
|
+
private get db();
|
|
56
|
+
/** The approver's backlog: rows waiting or claimed but not yet bound to a turn, oldest first. */
|
|
57
|
+
backlog(approver: string): LeadQueueRow[];
|
|
58
|
+
/** Every queue row of a thread (what the thread shows next to its messages). */
|
|
59
|
+
forThread(thread: number): LeadQueueRow[];
|
|
60
|
+
get(message: number): LeadQueueRow | null;
|
|
61
|
+
/**
|
|
62
|
+
* Send: run now, or wait — in ONE transaction, so a turn ending between the check and the insert can't strand a
|
|
63
|
+
* message. A request already saved with the same words and context is that message (never a second send, never a
|
|
64
|
+
* removed one resurrected); with different ones it conflicts. Busy means a personal lead turn of this person is
|
|
65
|
+
* queued or running, or something of theirs already waits.
|
|
66
|
+
*/
|
|
67
|
+
sendOrQueue(input: {
|
|
68
|
+
approver: string;
|
|
69
|
+
approverGeneration: number;
|
|
70
|
+
session: number;
|
|
71
|
+
ceilingDigest: string;
|
|
72
|
+
thread: number;
|
|
73
|
+
text: string;
|
|
74
|
+
requestId: string;
|
|
75
|
+
context: LeadQueueContext | null;
|
|
76
|
+
}, now: Date): SendDecision;
|
|
77
|
+
/**
|
|
78
|
+
* The next message to run: per person, only the oldest of their backlog, only while none of theirs runs, and only when
|
|
79
|
+
* `eligible` agrees (the runtime's admission stamp). Claiming keeps it in the backlog until its turn binds it.
|
|
80
|
+
*/
|
|
81
|
+
claimNext(runner: string, now: Date, eligible?: (row: LeadQueueRow) => boolean): LeadQueueClaim | null;
|
|
82
|
+
/** The claim still stands: running, this runner, this generation and revision, unbound or bound to `turn`. */
|
|
83
|
+
current(claim: LeadQueueClaim): boolean;
|
|
84
|
+
/** Inside the engine's admission transaction (onAdmitted): the claim binds to the turn that took it, exactly once. */
|
|
85
|
+
bindTurn(claim: LeadQueueClaim, turnId: number): boolean;
|
|
86
|
+
/** The claim ends: answered (its turn's reply is in the thread), failed or cancelled (words kept as the error), or
|
|
87
|
+
* uncertain (an outcome that can't be confirmed — never sent again). A message that never got a turn loses its words. */
|
|
88
|
+
finish(claim: LeadQueueClaim, result: {
|
|
89
|
+
status: "answered" | "failed" | "cancelled" | "uncertain";
|
|
90
|
+
error?: string;
|
|
91
|
+
turnId?: number;
|
|
92
|
+
}, now: Date): boolean;
|
|
93
|
+
/** A refusal before admission (a cap, a budget, a latch, another turn): back to waiting with the words to show. */
|
|
94
|
+
defer(claim: LeadQueueClaim, error: string): boolean;
|
|
95
|
+
/** Remove a waiting message (its author only, while it still waits): its words are forgotten, its request kept. */
|
|
96
|
+
remove(approver: string, message: number, now: Date): "removed" | "started" | "not-found";
|
|
97
|
+
/** Settle rows whose bound turn ended (by its recorded state), expire week-old waiting rows, and mark a claim that
|
|
98
|
+
* never bound by its turn deadline uncertain (the binding is the proof; elapsed time never resends). */
|
|
99
|
+
reconcile(now: Date, turnDeadlineMs: number): number;
|
|
100
|
+
/** End every waiting message sent under a session (it ended, or its person's chat was revoked). */
|
|
101
|
+
cancelForSession(session: number, why: string, now: Date): number;
|
|
102
|
+
/** A message that never ran keeps no words, context or search copy (it stays pending, so never history). */
|
|
103
|
+
private forgetWords;
|
|
104
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The personal lead queue (docs/plans/lead-message-queue.md): a message sent while one of its author's personal lead
|
|
3
|
+
* turns runs (or while earlier ones wait) waits here and runs one turn per message, in order — the same machinery
|
|
4
|
+
* shared team conversations use (team-leads.ts), for the person's own threads (/chat, a task's or a project's chat).
|
|
5
|
+
*
|
|
6
|
+
* The row's words are its lead_message (pending until the engine's admission binds it, which clears pending in the
|
|
7
|
+
* same statement). Admission itself is the engine's (runLeadTurn with queuedMessageId + onAdmitted): this module only
|
|
8
|
+
* decides queue-or-run at send, claims the backlog's oldest row, binds, finishes, defers, removes and expires.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
/** At most this many messages wait per person, and this many characters in all; a waiting message expires after 7 days. */
|
|
12
|
+
export const LEAD_QUEUE_MAX_MESSAGES = 10;
|
|
13
|
+
export const LEAD_QUEUE_MAX_CHARS = 20_000;
|
|
14
|
+
export const LEAD_QUEUE_EXPIRY_MS = 7 * 86_400_000;
|
|
15
|
+
/** The identity of a send: the same request with the same words and context is the same message. */
|
|
16
|
+
export function leadQueuePayloadHash(thread, text, context) {
|
|
17
|
+
return createHash("sha256").update(JSON.stringify([thread, text, context === null ? null : canonical(context)])).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
const canonical = (context) => Object.fromEntries(Object.entries(context).filter(([, value]) => value !== undefined).sort(([a], [b]) => a.localeCompare(b)));
|
|
20
|
+
const readRow = (row) => ({
|
|
21
|
+
message: Number(row["message"]), thread: Number(row["thread"]), approver: String(row["approver"]), approverGeneration: Number(row["approver_generation"]),
|
|
22
|
+
session: Number(row["session"]), ceilingDigest: String(row["ceiling_digest"]), requestId: String(row["request_id"]), payloadHash: String(row["payload_hash"]),
|
|
23
|
+
context: row["context_json"] === null ? null : JSON.parse(String(row["context_json"])), status: String(row["status"]),
|
|
24
|
+
revision: Number(row["revision"]), generation: Number(row["generation"]), runner: row["runner"] === null ? null : String(row["runner"]),
|
|
25
|
+
turnId: row["turn_id"] === null ? null : Number(row["turn_id"]), error: row["error"] === null ? null : String(row["error"]),
|
|
26
|
+
createdAt: String(row["created_at"]), text: String(row["text"] ?? ""),
|
|
27
|
+
});
|
|
28
|
+
const SELECT = "SELECT q.*, m.text FROM lead_queue q JOIN lead_message m ON m.id = q.message";
|
|
29
|
+
export class PersonalQueue {
|
|
30
|
+
store;
|
|
31
|
+
constructor(store) {
|
|
32
|
+
this.store = store;
|
|
33
|
+
}
|
|
34
|
+
get db() { return this.store.handle; }
|
|
35
|
+
/** The approver's backlog: rows waiting or claimed but not yet bound to a turn, oldest first. */
|
|
36
|
+
backlog(approver) {
|
|
37
|
+
return this.db.prepare(`${SELECT} WHERE q.approver = ? AND (q.status = 'queued' OR (q.status = 'running' AND q.turn_id IS NULL)) ORDER BY q.message`).all(approver).map(readRow);
|
|
38
|
+
}
|
|
39
|
+
/** Every queue row of a thread (what the thread shows next to its messages). */
|
|
40
|
+
forThread(thread) {
|
|
41
|
+
return this.db.prepare(`${SELECT} WHERE q.thread = ? ORDER BY q.message`).all(thread).map(readRow);
|
|
42
|
+
}
|
|
43
|
+
get(message) {
|
|
44
|
+
const row = this.db.prepare(`${SELECT} WHERE q.message = ?`).get(message);
|
|
45
|
+
return row === undefined ? null : readRow(row);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Send: run now, or wait — in ONE transaction, so a turn ending between the check and the insert can't strand a
|
|
49
|
+
* message. A request already saved with the same words and context is that message (never a second send, never a
|
|
50
|
+
* removed one resurrected); with different ones it conflicts. Busy means a personal lead turn of this person is
|
|
51
|
+
* queued or running, or something of theirs already waits.
|
|
52
|
+
*/
|
|
53
|
+
sendOrQueue(input, now) {
|
|
54
|
+
return this.store.transact(() => {
|
|
55
|
+
const hash = leadQueuePayloadHash(input.thread, input.text, input.context);
|
|
56
|
+
const seen = this.db.prepare(`${SELECT} WHERE q.session = ? AND q.request_id = ?`).get(input.session, input.requestId);
|
|
57
|
+
if (seen !== undefined)
|
|
58
|
+
return String(seen["payload_hash"]) === hash ? { kind: "queued", row: readRow(seen), already: true } : { kind: "conflict" };
|
|
59
|
+
const live = this.db.prepare(`SELECT 1 FROM mate_turn WHERE approver = ? AND state IN ('queued','running')
|
|
60
|
+
AND NOT EXISTS (SELECT 1 FROM team_conversation tc WHERE tc.thread = mate_turn.thread) LIMIT 1`).get(input.approver);
|
|
61
|
+
const waiting = this.backlog(input.approver);
|
|
62
|
+
if (live === undefined && waiting.length === 0)
|
|
63
|
+
return { kind: "run" };
|
|
64
|
+
if (waiting.length >= LEAD_QUEUE_MAX_MESSAGES)
|
|
65
|
+
return { kind: "full", message: `${LEAD_QUEUE_MAX_MESSAGES} messages are already waiting. Remove one, or wait for a reply.` };
|
|
66
|
+
if (waiting.reduce((sum, one) => sum + one.text.length, 0) + input.text.length > LEAD_QUEUE_MAX_CHARS) {
|
|
67
|
+
return { kind: "full", message: "The messages waiting are already long. Remove one, or wait for a reply." };
|
|
68
|
+
}
|
|
69
|
+
const message = this.store.appendLeadMessage({ thread: input.thread, turn: null, role: "operator", text: input.text, pending: true }, now);
|
|
70
|
+
this.db.prepare(`INSERT INTO lead_queue (message, thread, approver, approver_generation, session, ceiling_digest, request_id, payload_hash, context_json, status, created_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?)`).run(message, input.thread, input.approver, input.approverGeneration, input.session, input.ceilingDigest, input.requestId, hash, input.context === null ? null : JSON.stringify(canonical(input.context)), now.toISOString());
|
|
72
|
+
return { kind: "queued", row: this.get(message), already: false };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The next message to run: per person, only the oldest of their backlog, only while none of theirs runs, and only when
|
|
77
|
+
* `eligible` agrees (the runtime's admission stamp). Claiming keeps it in the backlog until its turn binds it.
|
|
78
|
+
*/
|
|
79
|
+
claimNext(runner, now, eligible = () => true) {
|
|
80
|
+
return this.store.transact(() => {
|
|
81
|
+
// An uncertain row does not hold the person's later messages (unlike a team room): its outcome is shown on it, and
|
|
82
|
+
// an unknown cost already pauses chat through its own latch.
|
|
83
|
+
const heads = this.db.prepare(`${SELECT} WHERE q.status = 'queued'
|
|
84
|
+
AND NOT EXISTS (SELECT 1 FROM lead_queue r WHERE r.approver = q.approver AND r.status = 'running')
|
|
85
|
+
AND NOT EXISTS (SELECT 1 FROM lead_queue e WHERE e.approver = q.approver AND e.status = 'queued' AND e.message < q.message)
|
|
86
|
+
ORDER BY q.message LIMIT 50`).all().map(readRow);
|
|
87
|
+
for (const row of heads) {
|
|
88
|
+
if (!eligible(row))
|
|
89
|
+
continue;
|
|
90
|
+
const changed = this.db.prepare("UPDATE lead_queue SET status = 'running', generation = generation + 1, runner = ?, claimed_at = ? WHERE message = ? AND status = 'queued'")
|
|
91
|
+
.run(runner, now.toISOString(), row.message);
|
|
92
|
+
if (Number(changed.changes) !== 1)
|
|
93
|
+
continue;
|
|
94
|
+
return { ...row, status: "running", runner, generation: row.generation + 1 };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** The claim still stands: running, this runner, this generation and revision, unbound or bound to `turn`. */
|
|
100
|
+
current(claim) {
|
|
101
|
+
return this.db.prepare("SELECT 1 FROM lead_queue WHERE message = ? AND status = 'running' AND runner = ? AND generation = ? AND revision = ?")
|
|
102
|
+
.get(claim.message, claim.runner, claim.generation, claim.revision) !== undefined;
|
|
103
|
+
}
|
|
104
|
+
/** Inside the engine's admission transaction (onAdmitted): the claim binds to the turn that took it, exactly once. */
|
|
105
|
+
bindTurn(claim, turnId) {
|
|
106
|
+
if (!this.current(claim))
|
|
107
|
+
return false;
|
|
108
|
+
const turn = this.store.getLeadTurn(turnId);
|
|
109
|
+
if (turn === null || turn.thread !== claim.thread || turn.approver !== claim.approver)
|
|
110
|
+
return false;
|
|
111
|
+
return Number(this.db.prepare("UPDATE lead_queue SET turn_id = ? WHERE message = ? AND turn_id IS NULL AND status = 'running' AND generation = ?")
|
|
112
|
+
.run(turnId, claim.message, claim.generation).changes) === 1;
|
|
113
|
+
}
|
|
114
|
+
/** The claim ends: answered (its turn's reply is in the thread), failed or cancelled (words kept as the error), or
|
|
115
|
+
* uncertain (an outcome that can't be confirmed — never sent again). A message that never got a turn loses its words. */
|
|
116
|
+
finish(claim, result, now) {
|
|
117
|
+
return this.store.transact(() => {
|
|
118
|
+
const row = this.db.prepare("SELECT turn_id FROM lead_queue WHERE message = ? AND status = 'running' AND runner = ? AND generation = ?").get(claim.message, claim.runner, claim.generation);
|
|
119
|
+
if (row === undefined)
|
|
120
|
+
return false;
|
|
121
|
+
if (result.turnId !== undefined && row["turn_id"] !== null && Number(row["turn_id"]) !== result.turnId)
|
|
122
|
+
return false;
|
|
123
|
+
this.db.prepare("UPDATE lead_queue SET status = ?, error = ?, turn_id = COALESCE(turn_id, ?), revision = revision + 1, settled_at = ? WHERE message = ?")
|
|
124
|
+
.run(result.status, result.error ?? null, result.turnId ?? null, now.toISOString(), claim.message);
|
|
125
|
+
if (row["turn_id"] === null && result.turnId === undefined)
|
|
126
|
+
this.forgetWords(claim.message);
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** A refusal before admission (a cap, a budget, a latch, another turn): back to waiting with the words to show. */
|
|
131
|
+
defer(claim, error) {
|
|
132
|
+
return Number(this.db.prepare(`UPDATE lead_queue SET status = 'queued', generation = generation + 1, revision = revision + 1, runner = NULL, claimed_at = NULL, error = ?
|
|
133
|
+
WHERE message = ? AND status = 'running' AND turn_id IS NULL AND runner = ? AND generation = ?`).run(error, claim.message, claim.runner, claim.generation).changes) === 1;
|
|
134
|
+
}
|
|
135
|
+
/** Remove a waiting message (its author only, while it still waits): its words are forgotten, its request kept. */
|
|
136
|
+
remove(approver, message, now) {
|
|
137
|
+
return this.store.transact(() => {
|
|
138
|
+
const row = this.db.prepare("SELECT status FROM lead_queue WHERE message = ? AND approver = ?").get(message, approver);
|
|
139
|
+
if (row === undefined)
|
|
140
|
+
return "not-found";
|
|
141
|
+
if (row["status"] !== "queued")
|
|
142
|
+
return "started";
|
|
143
|
+
this.db.prepare("UPDATE lead_queue SET status = 'cancelled', error = 'Removed.', revision = revision + 1, settled_at = ? WHERE message = ?").run(now.toISOString(), message);
|
|
144
|
+
this.forgetWords(message);
|
|
145
|
+
return "removed";
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** Settle rows whose bound turn ended (by its recorded state), expire week-old waiting rows, and mark a claim that
|
|
149
|
+
* never bound by its turn deadline uncertain (the binding is the proof; elapsed time never resends). */
|
|
150
|
+
reconcile(now, turnDeadlineMs) {
|
|
151
|
+
return this.store.transact(() => {
|
|
152
|
+
let changed = 0;
|
|
153
|
+
for (const row of this.db.prepare(`SELECT q.message, t.state, t.failure_reason FROM lead_queue q JOIN mate_turn t ON t.id = q.turn_id
|
|
154
|
+
WHERE q.status = 'running' AND t.state IN ('answered','failed')`).all()) {
|
|
155
|
+
const uncertain = this.store.leadTurnCostUnconfirmed(Number(this.db.prepare("SELECT turn_id FROM lead_queue WHERE message = ?").get(Number(row["message"]))["turn_id"]));
|
|
156
|
+
const status = row["state"] === "answered" ? "answered" : uncertain ? "uncertain" : "failed";
|
|
157
|
+
changed += Number(this.db.prepare("UPDATE lead_queue SET status = ?, error = ?, revision = revision + 1, settled_at = ? WHERE message = ? AND status = 'running'")
|
|
158
|
+
.run(status, status === "answered" ? null : String(row["failure_reason"] ?? "failed"), now.toISOString(), Number(row["message"])).changes);
|
|
159
|
+
}
|
|
160
|
+
const stale = new Date(now.getTime() - turnDeadlineMs).toISOString();
|
|
161
|
+
changed += Number(this.db.prepare("UPDATE lead_queue SET status = 'uncertain', error = 'Delivery could not be confirmed. Nothing was sent again.', revision = revision + 1, settled_at = ? WHERE status = 'running' AND turn_id IS NULL AND claimed_at < ?")
|
|
162
|
+
.run(now.toISOString(), stale).changes);
|
|
163
|
+
const old = new Date(now.getTime() - LEAD_QUEUE_EXPIRY_MS).toISOString();
|
|
164
|
+
for (const row of this.db.prepare("SELECT message FROM lead_queue WHERE status = 'queued' AND created_at < ?").all(old)) {
|
|
165
|
+
this.db.prepare("UPDATE lead_queue SET status = 'failed', error = 'Not sent: it waited too long.', revision = revision + 1, settled_at = ? WHERE message = ?").run(now.toISOString(), Number(row["message"]));
|
|
166
|
+
this.forgetWords(Number(row["message"]));
|
|
167
|
+
changed++;
|
|
168
|
+
}
|
|
169
|
+
return changed;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** End every waiting message sent under a session (it ended, or its person's chat was revoked). */
|
|
173
|
+
cancelForSession(session, why, now) {
|
|
174
|
+
return this.store.transact(() => {
|
|
175
|
+
const rows = this.db.prepare("SELECT message FROM lead_queue WHERE session = ? AND status = 'queued'").all(session);
|
|
176
|
+
for (const row of rows) {
|
|
177
|
+
this.db.prepare("UPDATE lead_queue SET status = 'cancelled', error = ?, revision = revision + 1, settled_at = ? WHERE message = ?").run(why, now.toISOString(), Number(row["message"]));
|
|
178
|
+
this.forgetWords(Number(row["message"]));
|
|
179
|
+
}
|
|
180
|
+
return rows.length;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** A message that never ran keeps no words, context or search copy (it stays pending, so never history). */
|
|
184
|
+
forgetWords(message) {
|
|
185
|
+
this.store.forgetQueuedLeadMessage(message);
|
|
186
|
+
}
|
|
187
|
+
}
|