toolroll 0.9.28 → 0.9.30
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 +20 -0
- package/dist/lead-status.js +17 -12
- package/dist/needs-you.d.ts +2 -2
- package/dist/needs-you.js +3 -0
- package/dist/operate.js +23 -2
- package/dist/phase-routing.js +1 -1
- package/dist/plan.d.ts +5 -3
- package/dist/plan.js +6 -3
- package/dist/process-liveness.d.ts +1 -0
- package/dist/process-liveness.js +5 -0
- package/dist/serve.js +11 -5
- package/dist/store.d.ts +8 -3
- package/dist/store.js +38 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.30 — 2026-10-04
|
|
4
|
+
|
|
5
|
+
- **Long tasks can be planned.** 0.9.28 let task goals run to 8,000
|
|
6
|
+
characters, but plans still had to fit the goal in 2,000, so every plan for a
|
|
7
|
+
longer task was refused. Plans now take the same limit as the task.
|
|
8
|
+
|
|
9
|
+
## 0.9.29 — 2026-10-04
|
|
10
|
+
|
|
11
|
+
- **Tasks filed from the command line get sized too.** `task scope` now sizes
|
|
12
|
+
the task from its goal before the plan is sealed, so work the lead files
|
|
13
|
+
gets the same fast-or-strong model choice as work filed in the console.
|
|
14
|
+
- **A reused process ID no longer blocks finishing a release.** Once
|
|
15
|
+
Toolroll has recorded that a process exited, a new program taking its
|
|
16
|
+
number doesn't count as the old one. Leftover process records close when the
|
|
17
|
+
run ends, and a wait that clears by itself says so instead of asking you to
|
|
18
|
+
confirm.
|
|
19
|
+
- **Status shows each task once.** A task that is building appears only under
|
|
20
|
+
Building, an approved plan no longer reads as waiting for review, and risky
|
|
21
|
+
routes say "Risky" instead of "routine".
|
|
22
|
+
|
|
3
23
|
## 0.9.28 — 2026-10-04
|
|
4
24
|
|
|
5
25
|
- **Each task gets the right model.** When a task is filed, a fast
|
package/dist/lead-status.js
CHANGED
|
@@ -114,19 +114,21 @@ export function taskWaitSnapshot(store, taskId, now, watchedRun) {
|
|
|
114
114
|
const next = holdKind === "operator" ? "Remove hold" : holdKind === "revision" ? "Review plan" : holdKind === "stop" ? "Resume or close attempt" : holdKind === "contest" ? "Choose a result" : "Review task";
|
|
115
115
|
return answer("Needs a person", next, true, 1, "needs-person");
|
|
116
116
|
}
|
|
117
|
-
if (task["plan"] === "drafted")
|
|
118
|
-
return answer("Needs a person", "Approve plan", true, 1, "needs-person");
|
|
119
|
-
if (Number(task["strikes"] ?? 0) >= 3)
|
|
120
|
-
return answer("Needs a person", "Review repeated failures", true, 1, "needs-person");
|
|
121
117
|
// An admitted attempt is already the work being watched. Its immutable
|
|
122
118
|
// approval snapshot lives on the run, so a damaged or later-edited current
|
|
123
119
|
// scope must not make `wait` abandon that attempt before it settles.
|
|
124
120
|
if (attempt !== undefined)
|
|
125
121
|
return answer("Running", phase === null ? "Wait for attempt" : `Wait — ${phase}`, false, null, null);
|
|
126
|
-
const scope = store.handle.prepare(
|
|
122
|
+
const scope = store.handle.prepare(`SELECT digest, approved_digest, profile_state,
|
|
123
|
+
(approved_at IS NOT NULL AND approved_by IS NOT NULL AND approved_digest = digest) AS approved
|
|
124
|
+
FROM task_scope WHERE task_id = ?`).get(taskId);
|
|
125
|
+
if (task["plan"] === "drafted" && Number(scope?.["approved"]) !== 1)
|
|
126
|
+
return answer("Needs a person", "Approve plan", true, 1, "needs-person");
|
|
127
|
+
if (Number(task["strikes"] ?? 0) >= 3)
|
|
128
|
+
return answer("Needs a person", "Review repeated failures", true, 1, "needs-person");
|
|
127
129
|
if (scope === undefined)
|
|
128
130
|
return answer("Needs a person", "Add scope", true, 1, "needs-person");
|
|
129
|
-
if (scope["
|
|
131
|
+
if (Number(scope["approved"]) !== 1 || scope["profile_state"] !== "resolved")
|
|
130
132
|
return answer("Needs a person", "Approve task", true, 1, "needs-person");
|
|
131
133
|
if (state === "running" && attempt === undefined) {
|
|
132
134
|
const liveClaim = store.handle.prepare(`SELECT 1 AS hit FROM claim INDEXED BY claim_by_task
|
|
@@ -157,7 +159,7 @@ function queueReason(row) {
|
|
|
157
159
|
return "needs a result choice";
|
|
158
160
|
if (hold === "backoff")
|
|
159
161
|
return "retrying later";
|
|
160
|
-
if (row["plan"] === "drafted")
|
|
162
|
+
if (row["plan"] === "drafted" && Number(row["approved"]) !== 1)
|
|
161
163
|
return "needs plan review";
|
|
162
164
|
if (Number(row["strikes"] ?? 0) >= 3)
|
|
163
165
|
return "stalled after failures";
|
|
@@ -165,7 +167,7 @@ function queueReason(row) {
|
|
|
165
167
|
return "waiting for another task";
|
|
166
168
|
if (row["scope_digest"] == null)
|
|
167
169
|
return "needs a scope";
|
|
168
|
-
if (row["
|
|
170
|
+
if (Number(row["approved"]) !== 1 || row["profile_state"] !== "resolved")
|
|
169
171
|
return "needs approval";
|
|
170
172
|
return "ready for a worker";
|
|
171
173
|
}
|
|
@@ -181,7 +183,7 @@ export function repairStaleStatuses(store, now) {
|
|
|
181
183
|
}
|
|
182
184
|
/** `viewer`: the person asking (the lead asks as its person); null when nobody is known, which shows no lead. */
|
|
183
185
|
export function installationStatus(store, now, viewer = currentActor()?.account ?? null) {
|
|
184
|
-
const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.external_id AS task
|
|
186
|
+
const runningRows = store.handle.prepare(`SELECT run.id, run.role, run.phase, ref.id AS task_ref, ref.external_id AS task
|
|
185
187
|
FROM run INDEXED BY work_unfinished
|
|
186
188
|
JOIN task_ref AS ref ON ref.id = run.task_ref
|
|
187
189
|
JOIN claim ON claim.lease_id = run.lease_id
|
|
@@ -194,8 +196,10 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
194
196
|
phases.set(phase, (phases.get(phase) ?? 0) + 1);
|
|
195
197
|
return { task: String(row["task"]), run: Number(row["id"]), phase };
|
|
196
198
|
});
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
+
const runningRefs = new Set(runningRows.map(row => Number(row["task_ref"])));
|
|
200
|
+
const queuedRows = store.handle.prepare(`SELECT task.id, ref.id AS task_ref, ref.plan, ref.strikes, scope.digest AS scope_digest,
|
|
201
|
+
scope.profile_state,
|
|
202
|
+
(scope.approved_at IS NOT NULL AND scope.approved_by IS NOT NULL AND scope.approved_digest = scope.digest) AS approved,
|
|
199
203
|
EXISTS (SELECT 1 FROM task_edge WHERE task_edge.blocked = task.id
|
|
200
204
|
AND EXISTS (SELECT 1 FROM task AS blocker WHERE blocker.id = task_edge.blocker AND blocker.state <> 'done')) AS blocked,
|
|
201
205
|
(SELECT owner_kind FROM hold INDEXED BY hold_by_task WHERE hold.task_ref = ref.id AND (hold.until IS NULL OR hold.until > ?)
|
|
@@ -203,7 +207,8 @@ export function installationStatus(store, now, viewer = currentActor()?.account
|
|
|
203
207
|
FROM task INDEXED BY task_by_state
|
|
204
208
|
JOIN task_ref AS ref INDEXED BY sqlite_autoindex_task_ref_1 ON ref.backend = ? AND ref.external_id = task.id
|
|
205
209
|
LEFT JOIN task_scope AS scope ON scope.task_id = task.id
|
|
206
|
-
WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
|
|
210
|
+
WHERE task.state = 'queued'`).all(now.toISOString(), BUILT_IN)
|
|
211
|
+
.filter(row => !runningRefs.has(Number(row["task_ref"])));
|
|
207
212
|
const reasons = new Map();
|
|
208
213
|
const queuedTasks = [];
|
|
209
214
|
const pauses = openAuthPauses(store);
|
package/dist/needs-you.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
/** What a person is asked for. */
|
|
9
9
|
export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "confirm-stopped" | "check-stopped" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "earlier-version" | "other";
|
|
10
10
|
/** What the work waits for when no person can act. */
|
|
11
|
-
export type WaitKey = "build-stopping" | "other-computer" | "card-reply" | "card-time" | "card-ci";
|
|
11
|
+
export type WaitKey = "build-stopping" | "build-finishing" | "other-computer" | "card-reply" | "card-time" | "card-ci";
|
|
12
12
|
/** The action's code: the WorkAction that owns the act, or one of the acts a surface renders itself. */
|
|
13
13
|
export type NeedActionCode = "approve-scope" | "approve-card" | "answer-decision" | "inspect-decisions" | "sign-in" | "confirm-stopped" | "inspect-hold" | "place-task" | "write-scope" | "select-agent" | "repair-dependency" | "repair-capability" | "start-worker" | "reconcile-run" | "open-result" | "retry-task" | "inspect-task";
|
|
14
14
|
export type NeedAction = {
|
|
@@ -73,7 +73,7 @@ export declare function waitSentence(key: WaitKey | undefined, reason: string |
|
|
|
73
73
|
* `unknown`: Toolroll can't check, so the person confirms they checked. */
|
|
74
74
|
export declare function processNeedOf(fact: {
|
|
75
75
|
run: number;
|
|
76
|
-
kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
|
|
76
|
+
kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
|
|
77
77
|
} | null): {
|
|
78
78
|
need: "confirm-stopped" | "check-stopped";
|
|
79
79
|
build: number;
|
package/dist/needs-you.js
CHANGED
|
@@ -79,6 +79,7 @@ export const NEED_ASK = {
|
|
|
79
79
|
"add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
|
|
80
80
|
};
|
|
81
81
|
export const WAITS = {
|
|
82
|
+
"build-finishing": () => "Finishing up; this clears on its own.",
|
|
82
83
|
"build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
|
|
83
84
|
"other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
|
|
84
85
|
"card-reply": () => "Waiting for a reply.",
|
|
@@ -110,6 +111,8 @@ export function waitSentence(key, reason, context = {}) {
|
|
|
110
111
|
export function processNeedOf(fact) {
|
|
111
112
|
if (fact === null)
|
|
112
113
|
return null;
|
|
114
|
+
if (fact.kind === "settling")
|
|
115
|
+
return { wait: "build-finishing", build: fact.run };
|
|
113
116
|
if (fact.kind === "unprovable")
|
|
114
117
|
return { need: "confirm-stopped", build: fact.run };
|
|
115
118
|
if (fact.kind === "unknown")
|
package/dist/operate.js
CHANGED
|
@@ -26,7 +26,7 @@ import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderInteg
|
|
|
26
26
|
import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
|
|
27
27
|
import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
|
|
28
28
|
import { pushLimitSink } from "./provider-limits.js";
|
|
29
|
-
import { installFilingSizer, ownerSizer, settleSizings } from "./task-sizing.js";
|
|
29
|
+
import { heuristicSizing, installFilingSizer, ownerSizer, refineFiledSizing, settleSizings } from "./task-sizing.js";
|
|
30
30
|
import { tierLines, tierReport } from "./tier-report.js";
|
|
31
31
|
import { limitsView } from "./limits-ui.js";
|
|
32
32
|
import { startCodexLimits } from "./codex-limits.js";
|
|
@@ -11110,7 +11110,7 @@ async function syncCommand(flags, context) {
|
|
|
11110
11110
|
* separate acts by, usually, separate parties: an agent may draft a scope, and
|
|
11111
11111
|
* only a person may agree to it.
|
|
11112
11112
|
*/
|
|
11113
|
-
function scopeTask(positional, flags, context) {
|
|
11113
|
+
async function scopeTask(positional, flags, context) {
|
|
11114
11114
|
const { store, write, json, now } = context;
|
|
11115
11115
|
const id = positional[0];
|
|
11116
11116
|
const goal = text(flags, "goal");
|
|
@@ -11265,6 +11265,27 @@ function scopeTask(positional, flags, context) {
|
|
|
11265
11265
|
const actor = asGiven !== undefined && tokenGiven !== undefined && authenticateApprover(store, asGiven, tokenGiven).ok
|
|
11266
11266
|
? asGiven
|
|
11267
11267
|
: null;
|
|
11268
|
+
// Size the new goal before filing its route. A replay must not classify
|
|
11269
|
+
// again or change the task behind the original answer. This probe does
|
|
11270
|
+
// not record a result; the composite filing below still owns the receipt.
|
|
11271
|
+
let refinement = null;
|
|
11272
|
+
store.replay(mutationFrom(flags, now), "task-scope-filed", () => {
|
|
11273
|
+
const ref = store.lookupRef(id);
|
|
11274
|
+
const previous = store.getScope(id);
|
|
11275
|
+
if (ref === null || ref.deliverable === "report" || candidateGiven !== undefined ||
|
|
11276
|
+
ref.sizing?.source === "person" || approvalOf(previous).approved ||
|
|
11277
|
+
previous?.proposedVia === "coordinator" || store.filedViaOf(id)?.startsWith("mcp:") ||
|
|
11278
|
+
((plannedRace !== null || plannedComparison !== null) && store.openAuthorizationFor(ref.id) !== null))
|
|
11279
|
+
return null;
|
|
11280
|
+
const input = { title: store.getTask(id).title, goal, outOfScope: text(flags, "not") ?? null, touches };
|
|
11281
|
+
const sized = store.applySizing(id, heuristicSizing(input), { followPlanning: true }, now);
|
|
11282
|
+
if (sized.ok)
|
|
11283
|
+
refinement = refineFiledSizing(store, id, input, true, context.clock);
|
|
11284
|
+
return null;
|
|
11285
|
+
}, () => false);
|
|
11286
|
+
// Wait for bounded classification before filing so any approval in the
|
|
11287
|
+
// transaction below binds the final size and route.
|
|
11288
|
+
await refinement;
|
|
11268
11289
|
// ONE replayed composite (surfaces round 1, finding 3): the filing, any
|
|
11269
11290
|
// race/comparison terms, AND the mode seal record as a single operation —
|
|
11270
11291
|
// a replayed key returns the FIRST answer whole instead of re-sealing
|
package/dist/phase-routing.js
CHANGED
|
@@ -806,7 +806,7 @@ export function projectRoute(route, readiness) {
|
|
|
806
806
|
return {
|
|
807
807
|
digest: routeDigestOf(route),
|
|
808
808
|
risk: route.risk,
|
|
809
|
-
riskTitle: riskTitle(route.risk),
|
|
809
|
+
riskTitle: route.risk === "routine" && route.size?.risky ? "Risky" : riskTitle(route.risk),
|
|
810
810
|
posture: drawsStrong(legs) ? "strong" : "economy",
|
|
811
811
|
postureWords: postureWords(route),
|
|
812
812
|
demands: route.demands.filter(reason => !/review/i.test(reason)),
|
package/dist/plan.d.ts
CHANGED
|
@@ -69,11 +69,13 @@ export type PlanParseResult = {
|
|
|
69
69
|
ok: false;
|
|
70
70
|
problems: PlanProblem[];
|
|
71
71
|
};
|
|
72
|
-
/** Caps matching the scope ritual's fields, plus the document itself.
|
|
72
|
+
/** Caps matching the scope ritual's fields, plus the document itself. The goal and out-of-scope text share the
|
|
73
|
+
* task's own limit: a plan reproduces the filed contract exactly, so a lower cap here would refuse every plan for a
|
|
74
|
+
* long goal. */
|
|
73
75
|
export declare const PLAN_LIMITS: {
|
|
74
76
|
readonly payload: number;
|
|
75
|
-
readonly goal:
|
|
76
|
-
readonly outOfScope:
|
|
77
|
+
readonly goal: 8000;
|
|
78
|
+
readonly outOfScope: 8000;
|
|
77
79
|
readonly touch: 200;
|
|
78
80
|
readonly touches: 32;
|
|
79
81
|
readonly document: number;
|
package/dist/plan.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { hasForbiddenControls } from "./decision.js";
|
|
10
10
|
import { parseAcceptanceCriteria } from "./scope.js";
|
|
11
|
+
import { TEXT_LIMITS } from "./text-limits.js";
|
|
11
12
|
const EXECUTION_PLAN_SECTIONS = ["approach", "milestones", "dependencies", "risks", "proof"];
|
|
12
13
|
const EXECUTION_PLAN_LIST_CAP = 12;
|
|
13
14
|
const EXECUTION_PLAN_ITEM_CAP = 600;
|
|
@@ -127,11 +128,13 @@ export function renderExecutionPlanDocument(document) {
|
|
|
127
128
|
"",
|
|
128
129
|
].join("\n");
|
|
129
130
|
}
|
|
130
|
-
/** Caps matching the scope ritual's fields, plus the document itself.
|
|
131
|
+
/** Caps matching the scope ritual's fields, plus the document itself. The goal and out-of-scope text share the
|
|
132
|
+
* task's own limit: a plan reproduces the filed contract exactly, so a lower cap here would refuse every plan for a
|
|
133
|
+
* long goal. */
|
|
131
134
|
export const PLAN_LIMITS = {
|
|
132
135
|
payload: 64 * 1024,
|
|
133
|
-
goal:
|
|
134
|
-
outOfScope:
|
|
136
|
+
goal: TEXT_LIMITS.goal,
|
|
137
|
+
outOfScope: TEXT_LIMITS.goal,
|
|
135
138
|
touch: 200,
|
|
136
139
|
touches: 32,
|
|
137
140
|
document: 16 * 1024,
|
package/dist/process-liveness.js
CHANGED
|
@@ -43,6 +43,11 @@ function newerMacProcess(pid, witness) {
|
|
|
43
43
|
* unknown stays occupied. This concerns the historical identity, not whether
|
|
44
44
|
* the numeric PID/PGID is currently populated by unrelated processes. */
|
|
45
45
|
export function processMayBeAlive(pid, group, witness) {
|
|
46
|
+
// A recorded single-process exit is final: this number can now belong to
|
|
47
|
+
// someone else, even before the run ends. A leader's exit proves nothing
|
|
48
|
+
// about its group's remaining members, so groups keep the OS probe.
|
|
49
|
+
if (!group && witness?.exitedAt != null)
|
|
50
|
+
return false;
|
|
46
51
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
47
52
|
return true;
|
|
48
53
|
if (group && process.platform !== "win32") {
|
package/dist/serve.js
CHANGED
|
@@ -12687,7 +12687,7 @@ function approvalSheetHtml(input) {
|
|
|
12687
12687
|
: projection === null
|
|
12688
12688
|
? route.legacy === null ? "" : `<p>${escape(agentsSummaryWords(route))}</p>`
|
|
12689
12689
|
: `<p>${escape(projection.summary)}</p>` +
|
|
12690
|
-
`<p class="meta">${escape(
|
|
12690
|
+
`<p class="meta">${escape(agentsRiskWords(projection))}. Uses ${escape(projection.postureWords)}.</p>` +
|
|
12691
12691
|
(projection.demands.length === 0 ? "" : `<ul class="meta">${projection.demands.map(one => `<li>${escape(one)}</li>`).join("")}</ul>`) +
|
|
12692
12692
|
`<dl class="approval-roles">${projection.legs.map(leg => `<div><dt>${escape(ROLE_NOUN[leg.phase])}</dt><dd><span class="mono">${escape(leg.provider)} · ${escape(leg.model)}</span> <span class="meta">${escape(chosenWords(leg))}</span>` +
|
|
12693
12693
|
`<ul>${leg.reasons.map(reason => `<li>${escape(reason)}</li>`).join("")}${leg.problem === null ? "" : `<li><strong>${escape(leg.problem)}</strong></li>`}</ul></dd></div>`).join("")}</dl>` +
|
|
@@ -12945,10 +12945,16 @@ function agentsSummaryWords(view) {
|
|
|
12945
12945
|
return `${view.legacy.provider} · ${view.legacy.model} builds and repairs (repair model ${view.legacy.repairModel}); the planner and reviewer come from configuration at run time`;
|
|
12946
12946
|
return view.problem ?? "the agents cannot be read";
|
|
12947
12947
|
}
|
|
12948
|
+
function agentsRiskWords(projection) {
|
|
12949
|
+
const consequence = projection.risk === "routine" && projection.size?.risky
|
|
12950
|
+
? riskConsequence("elevated")
|
|
12951
|
+
: riskConsequence(projection.risk);
|
|
12952
|
+
return `${projection.riskTitle}: ${consequence}`;
|
|
12953
|
+
}
|
|
12948
12954
|
/** Neutral metadata badges: risk, posture, and standing. */
|
|
12949
12955
|
function agentsBadgesHtml(view) {
|
|
12950
12956
|
const p = view.projection;
|
|
12951
|
-
return (`<span class="badge">${escape(sentenceCase(riskTitle(view.riskLevel)))}</span>` +
|
|
12957
|
+
return (`<span class="badge">${escape(sentenceCase(p?.riskTitle ?? riskTitle(view.riskLevel)))}</span>` +
|
|
12952
12958
|
(p === null ? "" : `<span class="badge">${escape(sentenceCase(p.postureWords))}</span>`) +
|
|
12953
12959
|
`<span class="badge">${escape(sentenceCase(agentsStandingWords(view)))}</span>`);
|
|
12954
12960
|
}
|
|
@@ -12997,8 +13003,8 @@ function agentsCeremonyHtml(view) {
|
|
|
12997
13003
|
return (`<div class="agents-ceremony"><p class="approval-label">agents</p>` +
|
|
12998
13004
|
sizeLineHtml(view.projection) +
|
|
12999
13005
|
`<p class="agents-summary">${escape(view.projection.summary)}</p>` +
|
|
13000
|
-
`<div class="agents-badges"><span class="badge">${escape(sentenceCase(
|
|
13001
|
-
`<p class="meta agents-risk-line">${escape(
|
|
13006
|
+
`<div class="agents-badges"><span class="badge">${escape(sentenceCase(view.projection.riskTitle))}</span><span class="badge">${escape(sentenceCase(view.projection.postureWords))}</span></div>` +
|
|
13007
|
+
`<p class="meta agents-risk-line">${escape(agentsRiskWords(view.projection))}.</p>` +
|
|
13002
13008
|
agentsWhyHtml(view.projection) +
|
|
13003
13009
|
`<p class="meta">These exact agents are part of what you approve; changing any of them asks for a fresh approval.</p></div>`);
|
|
13004
13010
|
}
|
|
@@ -13024,7 +13030,7 @@ function routineAgentsHtml(routine, approved, ceremony = false) {
|
|
|
13024
13030
|
const projection = projectRoute(route, () => null);
|
|
13025
13031
|
return (`<div class="agents-ceremony"><p class="approval-label">agents</p>` +
|
|
13026
13032
|
`<p class="agents-summary">${escape(agentsSummary(route))}</p>` +
|
|
13027
|
-
`<div class="agents-badges"><span class="badge">${escape(sentenceCase(riskTitle
|
|
13033
|
+
`<div class="agents-badges"><span class="badge">${escape(sentenceCase(projection.riskTitle))}</span><span class="badge">${escape(sentenceCase(postureWords(route)))}</span><span class="badge">${approved ? "frozen by the approval" : "frozen when you approve"}</span></div>` +
|
|
13028
13034
|
(ceremony ? agentsWhyHtml(projection) : "") +
|
|
13029
13035
|
`<p class="meta">${approved ? "Every firing runs on exactly these agents; a configuration change cannot re-route it." : "Approving freezes exactly these agents for every firing; a configuration change afterwards cannot re-route one."}</p></div>`);
|
|
13030
13036
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer }
|
|
|
26
26
|
* next; `channel` is where it reports (`chat`: the conversation it was promised in); a promise lapses at `expires_at`. */
|
|
27
27
|
export declare const LEAD_COMMITMENT_SCHEMA = "\nCREATE TABLE IF NOT EXISTS lead_commitment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n owner TEXT NOT NULL,\n repo TEXT,\n thread INTEGER NOT NULL,\n turn INTEGER,\n channel TEXT NOT NULL DEFAULT 'chat',\n what TEXT NOT NULL,\n condition_json TEXT NOT NULL,\n check_at TEXT NOT NULL,\n expires_at TEXT NOT NULL,\n state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'done', 'cancelled', 'expired')),\n created_at TEXT NOT NULL,\n checked_at TEXT,\n closed_at TEXT,\n closed_by TEXT,\n outcome TEXT\n);\nCREATE INDEX IF NOT EXISTS lead_commitment_due ON lead_commitment (state, check_at);\nCREATE INDEX IF NOT EXISTS lead_commitment_owner ON lead_commitment (owner, state, id);\n";
|
|
28
28
|
/** What stops a finished run reading stopped (Store.stopQuiescenceFact). */
|
|
29
|
-
export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
|
|
29
|
+
export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
|
|
30
30
|
export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
|
|
31
31
|
/** Store.stopQuiescenceFact, read safely: a deploy proves completion with this code over the INSTALLED runtime's store,
|
|
32
32
|
* which may predate the method. There the older stopQuiescenceProblem decides, and its words give the kind. */
|
|
@@ -7274,7 +7274,8 @@ export declare class Store {
|
|
|
7274
7274
|
* another computer can tell, `unprovable` when nothing is known to run
|
|
7275
7275
|
* but its exit was never recorded, `unknown` when this computer can't
|
|
7276
7276
|
* check at all (both are what `run settle` resolves; only the first may
|
|
7277
|
-
* be called not running).
|
|
7277
|
+
* be called not running). `settling` already meets the automatic
|
|
7278
|
+
* reconciliation proof, so no confirmation is needed. */
|
|
7278
7279
|
stopQuiescenceFact(runId: number): {
|
|
7279
7280
|
run: number;
|
|
7280
7281
|
kind: StopFactKind;
|
|
@@ -7306,7 +7307,11 @@ export declare class Store {
|
|
|
7306
7307
|
* process groups is alive, it settles as never started, with a ledger
|
|
7307
7308
|
* entry. A sole pid-less witness has no group to prove gone and stays for
|
|
7308
7309
|
* `run settle`. Native witnesses keep their OS-only road. */
|
|
7309
|
-
settleUnspawnedWitnesses(now: Date): number;
|
|
7310
|
+
settleUnspawnedWitnesses(now: Date, onlyRun?: number): number;
|
|
7311
|
+
/** The existing automatic settlement proof, shared with its read-only
|
|
7312
|
+
* status. Unknown-only, foreign, native and still-running custody keep
|
|
7313
|
+
* their fences; only an observed single-process exit avoids a new probe. */
|
|
7314
|
+
private settleableUnspawnedWitnesses;
|
|
7310
7315
|
/** `run settle` (an approver's last resort): every witness of a finished
|
|
7311
7316
|
* run that cannot be proven either way is recorded settled under the
|
|
7312
7317
|
* approver's reason, in the ledger. Refuses while anything of the run is
|
package/dist/store.js
CHANGED
|
@@ -16479,6 +16479,9 @@ export class Store {
|
|
|
16479
16479
|
// retained witnesses and every owned run; recovery revisits a pending
|
|
16480
16480
|
// stop after an orphan or held supervisor finishes shutdown.
|
|
16481
16481
|
this.recordRunProcessExits(id, result.now);
|
|
16482
|
+
// Use the existing reconciliation proof now, while exits are fresh. In
|
|
16483
|
+
// particular, a returned --candidate check need not await the next scan.
|
|
16484
|
+
this.settleUnspawnedWitnesses(result.now, id);
|
|
16482
16485
|
this.settleRunStop(id, result.stopSettlement ?? "finished", result.now);
|
|
16483
16486
|
// The first Ready result is an installation fact the moment it lands,
|
|
16484
16487
|
// not only when something later looks for it. A run that changed
|
|
@@ -21473,7 +21476,8 @@ export class Store {
|
|
|
21473
21476
|
* another computer can tell, `unprovable` when nothing is known to run
|
|
21474
21477
|
* but its exit was never recorded, `unknown` when this computer can't
|
|
21475
21478
|
* check at all (both are what `run settle` resolves; only the first may
|
|
21476
|
-
* be called not running).
|
|
21479
|
+
* be called not running). `settling` already meets the automatic
|
|
21480
|
+
* reconciliation proof, so no confirmation is needed. */
|
|
21477
21481
|
stopQuiescenceFact(runId) {
|
|
21478
21482
|
const fact = (run, kind, problem) => ({ run, kind, problem });
|
|
21479
21483
|
const ids = this.ownedRunsOf(runId);
|
|
@@ -21489,10 +21493,13 @@ export class Store {
|
|
|
21489
21493
|
if (held !== null && held.endedAt === null)
|
|
21490
21494
|
return fact(id, "alive", `run #${id}'s held supervisor has not finished shutdown`);
|
|
21491
21495
|
const witnesses = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(id);
|
|
21496
|
+
let incompleteSpawn = false;
|
|
21492
21497
|
if (run.providerStartedAt !== null && witnesses.length === 0) {
|
|
21493
21498
|
return fact(id, "unprovable", `run #${id} may have spawned before its process witness was recorded; exit is unproven`);
|
|
21494
21499
|
}
|
|
21495
21500
|
for (const witness of witnesses) {
|
|
21501
|
+
// A recorded group exit proves the whole group ended, not just its
|
|
21502
|
+
// leader. Re-probing its number could mistake reuse for the old group.
|
|
21496
21503
|
if (witness["exited_at"] !== null)
|
|
21497
21504
|
continue;
|
|
21498
21505
|
const host = String(witness["host"]);
|
|
@@ -21522,10 +21529,12 @@ export class Store {
|
|
|
21522
21529
|
return fact(id, "unknown", `run #${id}'s ${backend} object ${container} cannot be proven empty from here`);
|
|
21523
21530
|
continue;
|
|
21524
21531
|
}
|
|
21525
|
-
if (witness["pid"] === null)
|
|
21526
|
-
|
|
21532
|
+
if (witness["pid"] === null) {
|
|
21533
|
+
incompleteSpawn = true;
|
|
21534
|
+
continue;
|
|
21535
|
+
}
|
|
21527
21536
|
const pid = Number(witness["pid"]);
|
|
21528
|
-
if (processMayBeAlive(pid, witness["process_group"] === 1, { observedAt: witness["observed_at"], finishedAt: run.finishedAt }))
|
|
21537
|
+
if (processMayBeAlive(pid, witness["process_group"] === 1, { observedAt: witness["observed_at"], finishedAt: run.finishedAt, exitedAt: witness["exited_at"] }))
|
|
21529
21538
|
return fact(id, "alive", `run #${id}'s process ${pid} may still be running`);
|
|
21530
21539
|
}
|
|
21531
21540
|
if (run.worktree !== null) {
|
|
@@ -21540,6 +21549,8 @@ export class Store {
|
|
|
21540
21549
|
return fact(id, "unknown", `run #${id}'s workspace occupancy could not be established`);
|
|
21541
21550
|
}
|
|
21542
21551
|
}
|
|
21552
|
+
if (incompleteSpawn)
|
|
21553
|
+
return fact(id, this.settleableUnspawnedWitnesses(run, witnesses).length > 0 ? "settling" : "unprovable", `run #${id} has an incomplete spawn witness; exit is unproven`);
|
|
21543
21554
|
}
|
|
21544
21555
|
return null;
|
|
21545
21556
|
}
|
|
@@ -21618,10 +21629,11 @@ export class Store {
|
|
|
21618
21629
|
* process groups is alive, it settles as never started, with a ledger
|
|
21619
21630
|
* entry. A sole pid-less witness has no group to prove gone and stays for
|
|
21620
21631
|
* `run settle`. Native witnesses keep their OS-only road. */
|
|
21621
|
-
settleUnspawnedWitnesses(now) {
|
|
21632
|
+
settleUnspawnedWitnesses(now, onlyRun) {
|
|
21622
21633
|
const candidates = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
|
|
21623
21634
|
WHERE p.exited_at IS NULL AND p.pid IS NULL AND p.containment IS NULL AND p.container IS NULL
|
|
21624
|
-
AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL
|
|
21635
|
+
AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL
|
|
21636
|
+
AND (? IS NULL OR p.run = ?) ORDER BY p.run`).all(hostname(), onlyRun ?? null, onlyRun ?? null);
|
|
21625
21637
|
let settled = 0;
|
|
21626
21638
|
for (const candidate of candidates) {
|
|
21627
21639
|
const runId = Number(candidate["run"]);
|
|
@@ -21630,22 +21642,8 @@ export class Store {
|
|
|
21630
21642
|
const run = this.getRun(runId);
|
|
21631
21643
|
if (run === null || run.outcome === null)
|
|
21632
21644
|
return 0;
|
|
21633
|
-
if (ownedProcessCount(runOwnerTag(this, runId)) > 0)
|
|
21634
|
-
return 0;
|
|
21635
|
-
const held = this.heldSessionOf(runId);
|
|
21636
|
-
if (held !== null && held.endedAt === null)
|
|
21637
|
-
return 0;
|
|
21638
21645
|
const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(runId);
|
|
21639
|
-
const pidless =
|
|
21640
|
-
const others = rows.filter(row => !pidless.includes(row));
|
|
21641
|
-
if (pidless.length === 0 || others.length === 0)
|
|
21642
|
-
return 0;
|
|
21643
|
-
if (others.some(row => row["exited_at"] === null))
|
|
21644
|
-
return 0;
|
|
21645
|
-
// Exit was recorded once; the group's members are asked again now.
|
|
21646
|
-
const groups = others.filter(row => row["pid"] !== null && row["host"] === hostname());
|
|
21647
|
-
if (groups.length === 0 || groups.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt })))
|
|
21648
|
-
return 0;
|
|
21646
|
+
const pidless = this.settleableUnspawnedWitnesses(run, rows);
|
|
21649
21647
|
const ref = this.refById(run.taskRef);
|
|
21650
21648
|
let count = 0;
|
|
21651
21649
|
for (const row of pidless) {
|
|
@@ -21664,6 +21662,24 @@ export class Store {
|
|
|
21664
21662
|
}
|
|
21665
21663
|
return settled;
|
|
21666
21664
|
}
|
|
21665
|
+
/** The existing automatic settlement proof, shared with its read-only
|
|
21666
|
+
* status. Unknown-only, foreign, native and still-running custody keep
|
|
21667
|
+
* their fences; only an observed single-process exit avoids a new probe. */
|
|
21668
|
+
settleableUnspawnedWitnesses(run, rows) {
|
|
21669
|
+
if (run.outcome === null || run.finishedAt === null || ownedProcessCount(runOwnerTag(this, run.id)) > 0)
|
|
21670
|
+
return [];
|
|
21671
|
+
const held = this.heldSessionOf(run.id);
|
|
21672
|
+
if (held !== null && held.endedAt === null)
|
|
21673
|
+
return [];
|
|
21674
|
+
const pidless = rows.filter(row => row["exited_at"] === null && row["pid"] === null && row["containment"] === null && row["container"] === null && row["host"] === hostname());
|
|
21675
|
+
const others = rows.filter(row => !pidless.includes(row));
|
|
21676
|
+
if (pidless.length === 0 || others.length === 0 || others.some(row => row["exited_at"] === null))
|
|
21677
|
+
return [];
|
|
21678
|
+
const known = others.filter(row => row["pid"] !== null && row["host"] === hostname());
|
|
21679
|
+
if (known.length === 0 || known.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt, exitedAt: row["exited_at"] })))
|
|
21680
|
+
return [];
|
|
21681
|
+
return pidless;
|
|
21682
|
+
}
|
|
21667
21683
|
/** `run settle` (an approver's last resort): every witness of a finished
|
|
21668
21684
|
* run that cannot be proven either way is recorded settled under the
|
|
21669
21685
|
* approver's reason, in the ledger. Refuses while anything of the run is
|
|
@@ -21691,7 +21707,7 @@ export class Store {
|
|
|
21691
21707
|
return alive(`run #${args.runId}'s process ${String(row["pid"])} belongs to ${String(row["host"])}; settle it there`);
|
|
21692
21708
|
continue;
|
|
21693
21709
|
}
|
|
21694
|
-
if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt }))
|
|
21710
|
+
if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt, exitedAt: row["exited_at"] }))
|
|
21695
21711
|
return alive(`run #${args.runId}'s process ${String(row["pid"])} is still running`);
|
|
21696
21712
|
}
|
|
21697
21713
|
for (const row of open) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.30",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|