toolroll 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -24
- package/dist/accent-colors.d.ts +6 -3
- package/dist/accent-colors.js +9 -4
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +23 -23
- package/dist/browser-workspace.d.ts +46 -0
- package/dist/builder.d.ts +18 -1
- package/dist/builder.js +177 -5
- package/dist/chat-delivery.js +6 -4
- package/dist/claim.d.ts +15 -1
- package/dist/claim.js +30 -2
- package/dist/control-ui.js +1 -1
- package/dist/daemon.d.ts +9 -0
- package/dist/daemon.js +21 -0
- package/dist/decision.d.ts +6 -0
- package/dist/decision.js +12 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.d.ts +3 -1
- package/dist/dispatch.js +13 -0
- package/dist/dispose.js +11 -7
- package/dist/exhaustion.d.ts +14 -0
- package/dist/exhaustion.js +35 -0
- package/dist/install-method.d.ts +17 -0
- package/dist/install-method.js +61 -0
- package/dist/invoke.js +26 -0
- package/dist/lead-status.d.ts +7 -0
- package/dist/lead-status.js +15 -2
- package/dist/mate.js +5 -2
- package/dist/operate.d.ts +5 -0
- package/dist/operate.js +109 -15
- package/dist/provider-auth.d.ts +138 -0
- package/dist/provider-auth.js +291 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +8 -1
- package/dist/releases.d.ts +76 -0
- package/dist/releases.js +225 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +207 -76
- package/dist/setup-guide.d.ts +2 -2
- package/dist/setup-guide.js +2 -0
- package/dist/skills.d.ts +6 -0
- package/dist/skills.js +8 -0
- package/dist/store.d.ts +2 -2
- package/dist/store.js +13 -4
- package/dist/subscription-chat.js +3 -0
- package/dist/update-notice.d.ts +5 -0
- package/dist/update-notice.js +4 -0
- package/dist/work-index.js +22 -4
- package/dist/worktree.d.ts +13 -0
- package/dist/worktree.js +55 -29
- package/package.json +1 -1
|
@@ -241,6 +241,31 @@ export type BrowserSettingsView = {
|
|
|
241
241
|
redirect: string | null;
|
|
242
242
|
};
|
|
243
243
|
} | null;
|
|
244
|
+
/** Settings → Updates: this version, the latest known one and its notes, how to update, the daily-check switch, and each worker's version. */
|
|
245
|
+
updates?: BrowserUpdates | null;
|
|
246
|
+
};
|
|
247
|
+
export type BrowserUpdates = {
|
|
248
|
+
current: string;
|
|
249
|
+
/** The latest release the last check found; null before any check has worked. */
|
|
250
|
+
latest: {
|
|
251
|
+
version: string;
|
|
252
|
+
newer: boolean;
|
|
253
|
+
security: boolean;
|
|
254
|
+
notes: string;
|
|
255
|
+
url: string;
|
|
256
|
+
} | null;
|
|
257
|
+
updateCommand: string;
|
|
258
|
+
/** The daily check: off by TOOLROLL_NO_UPDATE_CHECK (byEnv) or by this switch; only an operator may flip it. */
|
|
259
|
+
check: {
|
|
260
|
+
on: boolean;
|
|
261
|
+
byEnv: boolean;
|
|
262
|
+
canManage: boolean;
|
|
263
|
+
};
|
|
264
|
+
workers: {
|
|
265
|
+
name: string;
|
|
266
|
+
version: string | null;
|
|
267
|
+
older: boolean;
|
|
268
|
+
}[];
|
|
244
269
|
};
|
|
245
270
|
/** A fold on the task page. Its HTML is the server's own section body, so
|
|
246
271
|
* forms, ids and page scripts are unchanged. */
|
|
@@ -754,6 +779,27 @@ export type BrowserWorkspace = {
|
|
|
754
779
|
view?: BrowserView | null;
|
|
755
780
|
/** A live page (Inbox, System…) reads itself again this often; forms being edited are kept. */
|
|
756
781
|
refreshSeconds?: number;
|
|
782
|
+
/** Providers whose sign-in stopped working: their work waits (one per provider). */
|
|
783
|
+
signIn?: BrowserSignIn[];
|
|
784
|
+
/** A newer Toolroll exists: a quiet notice for an operator, until they dismiss this version. */
|
|
785
|
+
update?: BrowserUpdateNotice;
|
|
786
|
+
};
|
|
787
|
+
/** The console's update notice: neutral, never the accent — an update does not need a person. */
|
|
788
|
+
export type BrowserUpdateNotice = {
|
|
789
|
+
version: string;
|
|
790
|
+
security: boolean;
|
|
791
|
+
href: string;
|
|
792
|
+
dismissHref: string;
|
|
793
|
+
};
|
|
794
|
+
/** A provider's sign-in pause, as the console shows it: the plain reason,
|
|
795
|
+
* what to run, and the one action that resumes its work. */
|
|
796
|
+
export type BrowserSignIn = {
|
|
797
|
+
provider: string;
|
|
798
|
+
title: string;
|
|
799
|
+
command: string;
|
|
800
|
+
detail: string;
|
|
801
|
+
resumeLabel: string;
|
|
802
|
+
resumeHref: string;
|
|
757
803
|
};
|
|
758
804
|
/** Safe inside a script[type=application/json] element. JSON escaping alone
|
|
759
805
|
* does not stop the HTML parser from closing that element at </script>. */
|
package/dist/builder.d.ts
CHANGED
|
@@ -165,7 +165,9 @@ export type BuildResult = {
|
|
|
165
165
|
};
|
|
166
166
|
export type BuildRefusal = "skills-unavailable"
|
|
167
167
|
/** Sprint 8: the organisation policy stops this provider, model or permission level. */
|
|
168
|
-
| "policy" | "unapproved" | "scope-changed" | "stale-approval" | "mode-ended" | "stale-authorization" | "attended-only" | "session-cap" | "attended-held" | "attended-unsupported" | "run-held" | "spawn-failed" | "capability" | "no-claim" | "not-yours" | "not-leased" | "protected-branch" | "wrong-branch" | "moved-branch" | "fenced" | "agent" | "agent-reported" | "no-op"
|
|
168
|
+
| "policy" | "unapproved" | "scope-changed" | "stale-approval" | "mode-ended" | "stale-authorization" | "attended-only" | "session-cap" | "attended-held" | "attended-unsupported" | "run-held" | "spawn-failed" | "capability" | "no-claim" | "not-yours" | "not-leased" | "protected-branch" | "wrong-branch" | "moved-branch" | "fenced" | "agent" | "agent-reported" | "no-op"
|
|
169
|
+
/** Run 2085: the agent stopped before its handoff; its work was kept for the retry. */
|
|
170
|
+
| "no-handoff" | "moved-head" | "timeout" | "git" | "commit-failure" | "malformed-decision" | "provider-init" | "setup" | "revision-brief" | "external" | "stopped" | "plan-revised" | "plan-revision-blocked" | "provider-unattested" | "provider-protocol" | "chain-credential" | "chain-custody" | "runner-custody" | "auth-mode" | "route-authority" | "containment";
|
|
169
171
|
/** Long enough for real work; short enough that a stuck build ends the same night. */
|
|
170
172
|
export declare const DEFAULT_BUILD_TIMEOUT_MS: number;
|
|
171
173
|
export declare const DEFAULT_MAX_TURNS = 40;
|
|
@@ -351,6 +353,21 @@ export declare function settleProviderOutcome(captured: CapturedBuild, result: A
|
|
|
351
353
|
* old name of every rename git paired (provenance, never a path). Anything
|
|
352
354
|
* missing, failed, tampered or unparseable reads as not captured. */
|
|
353
355
|
export declare function sealedDiffStatFacts(store: Store, root: string, statArtifactId: number): DiffStatFacts | null;
|
|
356
|
+
/**
|
|
357
|
+
* Read the mailbox, if the agent wrote one, and turn it into a package the
|
|
358
|
+
* caller can seal — or a problem list repair can work from.
|
|
359
|
+
*
|
|
360
|
+
* The payload is preserved as evidence *before* it is judged: a malformed
|
|
361
|
+
* park is still a person's best clue to what the agent meant, and the raw
|
|
362
|
+
* bytes leave the worktree either way — ingested once, then removed, so no
|
|
363
|
+
* later attempt can mistake them for its own agent's voice.
|
|
364
|
+
*/
|
|
365
|
+
/** What a person reads when an attempt stopped before its handoff (run 2085). */
|
|
366
|
+
export declare const NO_HANDOFF_WORDS = "The agent stopped before handing off; its work was kept and it is being resumed.";
|
|
367
|
+
/** The body of the work-in-progress commit an unresumable no-handoff attempt leaves on its branch. */
|
|
368
|
+
export declare const WIP_COMMIT_WORDS = "Work in progress: the agent stopped before handing off. Kept so the next attempt continues from it.";
|
|
369
|
+
/** The short turn a stopped agent's own session is resumed with. */
|
|
370
|
+
export declare function handoffResumePrompt(done: string): string;
|
|
354
371
|
/**
|
|
355
372
|
* What a non-zero agent exit means, in words a person can act on: the
|
|
356
373
|
* harness's own result line names the ending (a turn ceiling, an error
|
package/dist/builder.js
CHANGED
|
@@ -35,14 +35,14 @@ import { PREPARED_EVIDENCE_FILE, PREPARED_EVIDENCE_GIT, preparedScreenshotMatche
|
|
|
35
35
|
* watchdog and a high runaway-turn breaker still stop a pathological loop;
|
|
36
36
|
* repair remains narrowly time-bounded because its job is narrowly scoped.
|
|
37
37
|
*/
|
|
38
|
-
import { unlinkSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
|
39
39
|
import { homedir } from "node:os";
|
|
40
40
|
import { join } from "node:path";
|
|
41
41
|
import { run, runOwnerTag } from "./exec.js";
|
|
42
42
|
import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
|
|
43
43
|
import { witnessedRunner } from "./process-custody.js";
|
|
44
44
|
import { runWithIsolatedDatabase } from "./child-database.js";
|
|
45
|
-
import { recordWorktreeProcess } from "./worktree.js";
|
|
45
|
+
import { recordWorktreeProcess, saveWorkPatch } from "./worktree.js";
|
|
46
46
|
import { approvalOf, digestOf, profileDigestOf, chainDigestOf, entryDigestOf, routeParityProblem, profileFromJson } from "./scope.js";
|
|
47
47
|
import { legOf, routeDigestOf, routeFromJson } from "./phase-routing.js";
|
|
48
48
|
import { execFileSync } from "node:child_process";
|
|
@@ -50,7 +50,7 @@ import { currentClaim, finalizeRevisionFenced, heartbeat, SYNC_MAX_AGE_MS } from
|
|
|
50
50
|
import { missingCapability } from "./dispatch.js";
|
|
51
51
|
import { heartbeat as runnerHeartbeat } from "./runner.js";
|
|
52
52
|
import { MARKER as LEASE_MARKER } from "./worktree.js";
|
|
53
|
-
import { parseDecision, parseHandoff, repairPrompt, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
|
|
53
|
+
import { parseDecision, parseHandoff, repairPrompt, HEADLESS_RULE, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
|
|
54
54
|
import { createHash, randomUUID } from "node:crypto";
|
|
55
55
|
import { invokeAgent } from "./invoke.js";
|
|
56
56
|
import { TELEGRAM_TOKEN_ENVS } from "./names.js";
|
|
@@ -1540,6 +1540,13 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1540
1540
|
if (result.sessionId !== null) {
|
|
1541
1541
|
store.stampRun(request.runId, { sessionId: result.sessionId });
|
|
1542
1542
|
}
|
|
1543
|
+
// The unfinished handoff (run 2085): the agent ended its turn with work in
|
|
1544
|
+
// the tree and no handoff, park or proposal. Its own session is resumed in
|
|
1545
|
+
// this same worktree for a short turn to finish and hand off, before the
|
|
1546
|
+
// ordinary settlement below reads whatever that turn produced.
|
|
1547
|
+
const resumed = await resumeUnhandedWork(captured, result.sessionId ?? undefined);
|
|
1548
|
+
if (resumed !== null)
|
|
1549
|
+
return resumed;
|
|
1543
1550
|
// The adaptive-execution-plan settlement, checked at exactly the point
|
|
1544
1551
|
// park is: both are "stop without committing" endings, and both must be
|
|
1545
1552
|
// decided before the handoff is required of the agent at all.
|
|
@@ -1631,6 +1638,11 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1631
1638
|
// Missing or unremovable — either way the sweep and the commit-path
|
|
1632
1639
|
// exclusions keep it out of anybody's repository.
|
|
1633
1640
|
}
|
|
1641
|
+
if (!spoken.ok && spoken.missing) {
|
|
1642
|
+
const kept = await keepUnhandedWork(captured, result.sessionId ?? undefined);
|
|
1643
|
+
if (kept !== null)
|
|
1644
|
+
return kept;
|
|
1645
|
+
}
|
|
1634
1646
|
if (!spoken.ok) {
|
|
1635
1647
|
return {
|
|
1636
1648
|
ok: false,
|
|
@@ -1757,7 +1769,8 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1757
1769
|
return { ok: true, committed: false, noChange: true, branch, summary: handoff.conclusion };
|
|
1758
1770
|
}
|
|
1759
1771
|
// completed
|
|
1760
|
-
|
|
1772
|
+
const finishesKeptWork = dirty.length === 0 && await startsFromKeptWork(captured, baseRevision, pinnedBase);
|
|
1773
|
+
if (dirty.length === 0 && !finishesKeptWork) {
|
|
1761
1774
|
return {
|
|
1762
1775
|
ok: false,
|
|
1763
1776
|
reason: "no-op",
|
|
@@ -1775,7 +1788,11 @@ export async function settleProviderOutcome(captured, result) {
|
|
|
1775
1788
|
};
|
|
1776
1789
|
}
|
|
1777
1790
|
store.setRunPhase(request.runId, "committing");
|
|
1778
|
-
|
|
1791
|
+
// Kept work that is already complete is already committed: the attempt's
|
|
1792
|
+
// result is that commit, sealed below against the task's original base.
|
|
1793
|
+
const made = finishesKeptWork
|
|
1794
|
+
? await keptWorkResult(captured, baseRevision, handoff.conclusion)
|
|
1795
|
+
: await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null);
|
|
1779
1796
|
if (made.ok && made.parked === undefined && made.committed) {
|
|
1780
1797
|
const newHead = await git(GIT, ["--no-optional-locks", "rev-parse", "HEAD"], { cwd: worktree });
|
|
1781
1798
|
if (newHead.code === 0) {
|
|
@@ -2442,6 +2459,160 @@ function screenshotFileTag(path) {
|
|
|
2442
2459
|
* bytes leave the worktree either way — ingested once, then removed, so no
|
|
2443
2460
|
* later attempt can mistake them for its own agent's voice.
|
|
2444
2461
|
*/
|
|
2462
|
+
/** What a person reads when an attempt stopped before its handoff (run 2085). */
|
|
2463
|
+
export const NO_HANDOFF_WORDS = "The agent stopped before handing off; its work was kept and it is being resumed.";
|
|
2464
|
+
/** The body of the work-in-progress commit an unresumable no-handoff attempt leaves on its branch. */
|
|
2465
|
+
export const WIP_COMMIT_WORDS = "Work in progress: the agent stopped before handing off. Kept so the next attempt continues from it.";
|
|
2466
|
+
/**
|
|
2467
|
+
* Whether this attempt starts from the work-in-progress commit an earlier
|
|
2468
|
+
* no-handoff attempt of this task left (keepUnhandedWork), with changes
|
|
2469
|
+
* against the task's original base. That commit's changes are the unfinished
|
|
2470
|
+
* work this attempt was sent to finish, so they count as its own: an agent
|
|
2471
|
+
* that finds them complete and hands off `completed` with a clean tree has
|
|
2472
|
+
* done the work, not claimed work it never did.
|
|
2473
|
+
*/
|
|
2474
|
+
async function startsFromKeptWork(captured, baseRevision, pinnedBase) {
|
|
2475
|
+
const { store, request, git, worktree, branch } = captured;
|
|
2476
|
+
if (pinnedBase === baseRevision)
|
|
2477
|
+
return false;
|
|
2478
|
+
const kept = store.runsFor(request.taskRef).some(one => one.id < request.runId && one.role === "builder" && one.branch === branch && one.outcome === "failed" && one.reason === "no-handoff");
|
|
2479
|
+
if (!kept)
|
|
2480
|
+
return false;
|
|
2481
|
+
const message = await git(GIT, ["--no-optional-locks", "log", "-1", "--format=%B", baseRevision], { cwd: worktree });
|
|
2482
|
+
if (message.code !== 0 || !message.stdout.includes(WIP_COMMIT_WORDS))
|
|
2483
|
+
return false;
|
|
2484
|
+
// --quiet exits 1 exactly when the two sides differ.
|
|
2485
|
+
const changed = await git(GIT, ["--no-optional-locks", "diff", "--quiet", pinnedBase, baseRevision, "--"], { cwd: worktree });
|
|
2486
|
+
return changed.code === 1;
|
|
2487
|
+
}
|
|
2488
|
+
/** The accepted result of an attempt that finished kept work with nothing left to change. An exact prepared candidate must still match it. */
|
|
2489
|
+
async function keptWorkResult(captured, head, summary) {
|
|
2490
|
+
const { git, worktree, branch, request, scope } = captured;
|
|
2491
|
+
const candidate = request.attended === undefined ? scope?.candidate ?? null : null;
|
|
2492
|
+
if (candidate !== null) {
|
|
2493
|
+
const exact = await git(GIT, ["--no-optional-locks", "diff", "--quiet", candidate, head, "--"], { cwd: worktree });
|
|
2494
|
+
if (exact.code !== 0)
|
|
2495
|
+
return { ok: false, reason: "commit-failure", message: exact.code === 1
|
|
2496
|
+
? "The kept work-in-progress commit does not match the approved prepared candidate. The checkout is preserved."
|
|
2497
|
+
: "The kept work-in-progress commit could not be checked against the approved prepared candidate. The checkout is preserved." };
|
|
2498
|
+
}
|
|
2499
|
+
return { ok: true, committed: true, branch, summary };
|
|
2500
|
+
}
|
|
2501
|
+
/** The short turn a stopped agent's own session is resumed with. */
|
|
2502
|
+
export function handoffResumePrompt(done) {
|
|
2503
|
+
return [
|
|
2504
|
+
`Your last turn ended before the handoff. Finish the task and write ${done}.`,
|
|
2505
|
+
"Every rule from the original brief still applies.",
|
|
2506
|
+
...HEADLESS_RULE,
|
|
2507
|
+
].join("\n");
|
|
2508
|
+
}
|
|
2509
|
+
/** The agent's work in the tree: protocol files and the lease note aside. Null when git cannot say. */
|
|
2510
|
+
async function unhandedChanges(git, worktree) {
|
|
2511
|
+
const status = await git(GIT, ["--no-optional-locks", "status", "--porcelain"], { cwd: worktree });
|
|
2512
|
+
if (status.code !== 0)
|
|
2513
|
+
return null;
|
|
2514
|
+
return status.stdout
|
|
2515
|
+
.split("\n")
|
|
2516
|
+
.filter(line => line.trim() !== "" &&
|
|
2517
|
+
!line.trimEnd().endsWith(LEASE_MARKER) &&
|
|
2518
|
+
!(line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3))));
|
|
2519
|
+
}
|
|
2520
|
+
/** Whether this attempt's own session can be resumed in place. */
|
|
2521
|
+
function canResumeSession(captured, sessionId) {
|
|
2522
|
+
return sessionId !== undefined && sessionId.trim() !== "" && auditOf(captured.request.provider ?? "claude").resume === "native";
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Resume the agent's own session when it ended its turn with changes but no
|
|
2526
|
+
* handoff, park or plan proposal (run 2085: tests left running in the
|
|
2527
|
+
* background, a wakeup scheduled, the headless process gone). Each turn is a
|
|
2528
|
+
* repair-role child run under the same admission and budget as a park
|
|
2529
|
+
* repair, in the same worktree, with the build's own limits: finishing the
|
|
2530
|
+
* task can need a full check run. Nothing here reads or accepts a handoff —
|
|
2531
|
+
* the ordinary settlement does that afterwards, unweakened.
|
|
2532
|
+
*/
|
|
2533
|
+
async function resumeUnhandedWork(captured, sessionId) {
|
|
2534
|
+
const { store, request, agent, git, worktree, mailbox, done, effective, clock, taskId } = captured;
|
|
2535
|
+
const waiting = () => !existsSync(join(worktree, done)) &&
|
|
2536
|
+
!existsSync(join(worktree, mailbox)) &&
|
|
2537
|
+
(captured.plan === undefined || !existsSync(join(worktree, captured.plan.proposal)));
|
|
2538
|
+
// A watched (attended) session has an operator; nothing resumes it headless.
|
|
2539
|
+
if (request.attended !== undefined || !waiting() || !canResumeSession(captured, sessionId))
|
|
2540
|
+
return null;
|
|
2541
|
+
const changes = await unhandedChanges(git, worktree);
|
|
2542
|
+
if (changes === null || changes.length === 0)
|
|
2543
|
+
return null;
|
|
2544
|
+
const provider = request.provider ?? "claude";
|
|
2545
|
+
const model = repairModelOf(effective.profile, request);
|
|
2546
|
+
for (let turn = 0; turn < REPAIR_TURNS && waiting(); turn++) {
|
|
2547
|
+
if (stopRequestedFor(store, request.runId, request.shouldStop))
|
|
2548
|
+
return null;
|
|
2549
|
+
if (request.leaseId !== undefined && !heartbeat(store, request.leaseId, clock()).ok) {
|
|
2550
|
+
return { ok: false, reason: "fenced", message: `${taskId}'s lease did not survive the resumed turn — the work is still in ${worktree}` };
|
|
2551
|
+
}
|
|
2552
|
+
if (store.budgetGate(clock())({ ...store.budgetSubject(request.taskRef), agents: [{ provider, billing: store.runBilling(request.runId) ?? store.agentsFor([provider])[0].billing }] }).over !== null)
|
|
2553
|
+
break;
|
|
2554
|
+
if (store.agentPolicyRefusal(provider, model ?? null) !== null)
|
|
2555
|
+
break;
|
|
2556
|
+
const admitted = admitProtocolRepair(store, request, effective.profile, sessionId, clock);
|
|
2557
|
+
if (!admitted.ok)
|
|
2558
|
+
break;
|
|
2559
|
+
const spoken = await invokeAgent(store, admitted.runId, { provider, model }, {
|
|
2560
|
+
phase: "build",
|
|
2561
|
+
brief: handoffResumePrompt(done),
|
|
2562
|
+
maxTurns: effective.maxTurns ?? request.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
2563
|
+
permissionMode: effective.profile.provider === "claude" && effective.profile.permissionArgv !== "bypassPermissions"
|
|
2564
|
+
? effective.profile.permissionArgv
|
|
2565
|
+
: (request.permissionMode ?? "auto"),
|
|
2566
|
+
skipPermissions: effective.skipPermissions,
|
|
2567
|
+
resumeSession: sessionId,
|
|
2568
|
+
// The build's own spend cap holds for the turn that finishes it.
|
|
2569
|
+
...(request.maxBudgetUsd === undefined ? {} : { maxBudgetUsd: request.maxBudgetUsd }),
|
|
2570
|
+
}, {
|
|
2571
|
+
cwd: worktree,
|
|
2572
|
+
...(effective.profile.timeoutKind === "idle" ? { idleTimeoutMs: effective.timeoutMs } : { timeoutMs: effective.timeoutMs }),
|
|
2573
|
+
omitEnv: AGENT_ENV_DENYLIST,
|
|
2574
|
+
...(agent === undefined ? {} : { runner: agent }),
|
|
2575
|
+
...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
|
|
2576
|
+
clock,
|
|
2577
|
+
});
|
|
2578
|
+
const handedOff = !waiting();
|
|
2579
|
+
const ran = spoken.kind === "ran" && !spoken.outcome.timedOut && spoken.outcome.code === 0 && !spoken.outcome.initFailed;
|
|
2580
|
+
store.finishRun(admitted.runId, {
|
|
2581
|
+
outcome: handedOff && ran ? "built" : "failed",
|
|
2582
|
+
reason: spoken.kind === "refused" ? spoken.reason : handedOff ? "resumed-handoff" : ran ? "no-handoff" : spoken.outcome.timedOut ? "timeout" : "agent",
|
|
2583
|
+
now: clock(),
|
|
2584
|
+
});
|
|
2585
|
+
if (request.leaseId !== undefined && !heartbeat(store, request.leaseId, clock()).ok) {
|
|
2586
|
+
return { ok: false, reason: "fenced", message: `${taskId}'s lease did not survive the resumed turn — the work is still in ${worktree}` };
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
return null;
|
|
2590
|
+
}
|
|
2591
|
+
/**
|
|
2592
|
+
* The attempt ended without a handoff, even after any resumed turn. When it
|
|
2593
|
+
* left changes, they are never discarded: first saved as a patch in this
|
|
2594
|
+
* run's evidence folder, then — when its session cannot be resumed — kept as
|
|
2595
|
+
* a work-in-progress commit on the branch the next attempt continues from.
|
|
2596
|
+
* Otherwise they stay in place, and the next lease keeps them for the retry.
|
|
2597
|
+
* Null when there was nothing to keep: the ordinary no-op words apply.
|
|
2598
|
+
*/
|
|
2599
|
+
async function keepUnhandedWork(captured, sessionId) {
|
|
2600
|
+
const { store, request, git, worktree, branch, taskId, scope, root, clock } = captured;
|
|
2601
|
+
const changes = await unhandedChanges(git, worktree);
|
|
2602
|
+
if (changes === null || changes.length === 0)
|
|
2603
|
+
return null;
|
|
2604
|
+
const saved = await saveWorkPatch(git, worktree, join(root, String(request.runId), "unhanded-work.patch"), `# work left without a handoff in ${worktree}\n# kept ${clock().toISOString()}\n`);
|
|
2605
|
+
if (!saved.ok) {
|
|
2606
|
+
return { ok: false, reason: "no-handoff", message: `${NO_HANDOFF_WORDS} Its changes stay uncommitted in ${worktree}; ${saved.message}.` };
|
|
2607
|
+
}
|
|
2608
|
+
if (request.attended === undefined && !canResumeSession(captured, sessionId) && scope !== null && !stopRequestedFor(store, request.runId, request.shouldStop)) {
|
|
2609
|
+
const made = await commit(git, worktree, branch, taskId, scope, WIP_COMMIT_WORDS);
|
|
2610
|
+
if (made.ok && "committed" in made && made.committed) {
|
|
2611
|
+
return { ok: false, reason: "no-handoff", message: `${NO_HANDOFF_WORDS} It was saved as a work-in-progress commit on ${branch}.` };
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
return { ok: false, reason: "no-handoff", message: NO_HANDOFF_WORDS };
|
|
2615
|
+
}
|
|
2445
2616
|
async function ingestPark(args) {
|
|
2446
2617
|
const { store, request, agent, git, worktree, mailbox, baseRevision, root } = args;
|
|
2447
2618
|
const path = join(worktree, mailbox);
|
|
@@ -2958,6 +3129,7 @@ planRevision = null) {
|
|
|
2958
3129
|
`- You are on branch ${branch}. Do not switch branches, and never commit to main.`,
|
|
2959
3130
|
"- Do not push, open a pull request, or run any network write.",
|
|
2960
3131
|
"- Stay inside this worktree.",
|
|
3132
|
+
...HEADLESS_RULE,
|
|
2961
3133
|
...(revisionBrief === null ? [] : [
|
|
2962
3134
|
"- If the sealed revision kind is evidence-observation, collect observations only.",
|
|
2963
3135
|
" Keep every repository file and HEAD unchanged; do not run the full suite.",
|
package/dist/chat-delivery.js
CHANGED
|
@@ -595,12 +595,14 @@ export async function planChatNotifications(options) {
|
|
|
595
595
|
if (notification.createdAt >= binding.created &&
|
|
596
596
|
notification.resolvedAt === null &&
|
|
597
597
|
// A flow decision for "anyone who approves" reaches every approver who can see the project.
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
598
|
+
// A notification addressed to this person reaches them whatever project
|
|
599
|
+
// their channel follows (a sign-in pause is the installation's, v108).
|
|
600
|
+
(personal
|
|
601
|
+
? notification.recipient === binding.approver && (notification.project === null || repos.includes(notification.project))
|
|
602
|
+
: (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
|
|
601
603
|
const run = store.telegramProgressRun(notification);
|
|
602
604
|
const id = chatHash(`${options.state.channel}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
|
|
603
|
-
if (run && notification.taskId && isTelegramProgressNotification(notification)) {
|
|
605
|
+
if (run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
|
|
604
606
|
const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
|
|
605
607
|
const content = {
|
|
606
608
|
text: card.text,
|
package/dist/claim.d.ts
CHANGED
|
@@ -350,7 +350,9 @@ export declare function finalizeMalformedFenced(store: Store, args: {
|
|
|
350
350
|
reason: "fenced" | "unknown" | "stopped";
|
|
351
351
|
};
|
|
352
352
|
/** How a failed attempt is classified, per gnhf. Decides retry, backoff, or stall. */
|
|
353
|
-
export type FailureClass = "agent-reported" | "retryable-infra" | "no-op"
|
|
353
|
+
export type FailureClass = "agent-reported" | "retryable-infra" | "no-op"
|
|
354
|
+
/** Run 2085: stopped before its handoff; the work was kept and the retry resumes it. Strikes like no-op. */
|
|
355
|
+
| "no-handoff" | "commit-failure" | "unknown";
|
|
354
356
|
export declare const MAX_STRIKES = 3;
|
|
355
357
|
export type FailureDisposition = {
|
|
356
358
|
ok: true;
|
|
@@ -366,6 +368,10 @@ export type FailureDisposition = {
|
|
|
366
368
|
ok: true;
|
|
367
369
|
disposition: "commit-incident";
|
|
368
370
|
incidentId: number;
|
|
371
|
+
} | {
|
|
372
|
+
ok: true;
|
|
373
|
+
disposition: "auth-expired";
|
|
374
|
+
provider: string;
|
|
369
375
|
} | {
|
|
370
376
|
ok: false;
|
|
371
377
|
reason: "fenced" | "unknown" | "stopped";
|
|
@@ -580,6 +586,10 @@ export type PlanFailureDisposition = {
|
|
|
580
586
|
disposition: "exhausted";
|
|
581
587
|
incidentId: number;
|
|
582
588
|
strikes: number;
|
|
589
|
+
} | {
|
|
590
|
+
ok: true;
|
|
591
|
+
disposition: "auth-expired";
|
|
592
|
+
provider: string;
|
|
583
593
|
} | {
|
|
584
594
|
ok: false;
|
|
585
595
|
reason: "fenced" | "unknown" | "stopped";
|
|
@@ -652,6 +662,10 @@ export type ScoutFailureDisposition = {
|
|
|
652
662
|
disposition: "stalled";
|
|
653
663
|
incidentId: number;
|
|
654
664
|
strikes: number;
|
|
665
|
+
} | {
|
|
666
|
+
ok: true;
|
|
667
|
+
disposition: "auth-expired";
|
|
668
|
+
provider: string;
|
|
655
669
|
} | {
|
|
656
670
|
ok: false;
|
|
657
671
|
reason: "fenced" | "unknown" | "stopped";
|
package/dist/claim.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { randomUUID } from "node:crypto";
|
|
31
31
|
import { attendedLivenessState } from "./liveness.js";
|
|
32
32
|
import { authenticate } from "./runner.js";
|
|
33
|
+
import { pauseForAuth } from "./provider-auth.js";
|
|
33
34
|
import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
|
|
34
35
|
/** attendedLivenessState over the store's ISO columns. */
|
|
35
36
|
function attendedWatchState(lastBeatAt, now, absoluteExpiry) {
|
|
@@ -1059,6 +1060,11 @@ export function finalizeFailureFenced(store, args) {
|
|
|
1059
1060
|
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1060
1061
|
return refusal(db, leaseId);
|
|
1061
1062
|
}
|
|
1063
|
+
// A sign-in that no longer works is not the attempt's fault: no strike,
|
|
1064
|
+
// no retry, the task back in the queue, and its provider paused.
|
|
1065
|
+
if (failureClass !== "commit-failure" && run.terminalClass === "auth-expired") {
|
|
1066
|
+
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1067
|
+
}
|
|
1062
1068
|
store.finishRun(runId, { outcome: "failed", reason: failureClass, now });
|
|
1063
1069
|
if (failureClass === "commit-failure") {
|
|
1064
1070
|
// The work exists and is uncommitted; nothing times its way out of
|
|
@@ -1113,19 +1119,37 @@ export function finalizeFailureFenced(store, args) {
|
|
|
1113
1119
|
taskRef: run.taskRef,
|
|
1114
1120
|
ownerKind: "backoff",
|
|
1115
1121
|
ownerId: String(run.taskRef),
|
|
1116
|
-
|
|
1122
|
+
// Run 2085: this failure is named in plain words wherever the hold shows.
|
|
1123
|
+
reason: failureClass === "no-handoff"
|
|
1124
|
+
? `The agent stopped before handing off; its work was kept and it is being resumed (retry ${strikes}/${MAX_STRIKES} in ${Math.round(wait / 60_000)}m)`
|
|
1125
|
+
: `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
|
|
1117
1126
|
until,
|
|
1118
1127
|
}, now);
|
|
1119
1128
|
store.enqueueNotification({
|
|
1120
1129
|
source: { run: runId },
|
|
1121
1130
|
dedupeKey: `run:${runId}:failed`,
|
|
1122
1131
|
kind: "build-failed",
|
|
1123
|
-
subject:
|
|
1132
|
+
subject: failureClass === "no-handoff"
|
|
1133
|
+
? `${taskId}: the agent stopped before handing off; its work was kept, retry ${strikes}/${MAX_STRIKES}`
|
|
1134
|
+
: `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
|
|
1124
1135
|
body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
|
|
1125
1136
|
}, now);
|
|
1126
1137
|
return { ok: true, disposition: "backoff", strikes, until: until.toISOString() };
|
|
1127
1138
|
});
|
|
1128
1139
|
}
|
|
1140
|
+
/**
|
|
1141
|
+
* The auth-expired ending, inside the caller's fenced transaction (claim
|
|
1142
|
+
* already released): the run fails as `auth-expired`, the task goes back to
|
|
1143
|
+
* the queue with no strike and no backoff, and dispatch for the run's
|
|
1144
|
+
* provider pauses — the first such run of an incident tells a person once.
|
|
1145
|
+
*/
|
|
1146
|
+
function sealAuthExpired(store, run, taskId, now) {
|
|
1147
|
+
store.finishRun(run.id, { outcome: "failed", reason: "auth-expired", now });
|
|
1148
|
+
store.handle.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'").run(now.toISOString(), taskId);
|
|
1149
|
+
pauseForAuth(store, { provider: run.provider, authMode: run.authMode ?? null, runId: run.id, taskRef: run.taskRef, now });
|
|
1150
|
+
store.bumpWake();
|
|
1151
|
+
return { disposition: "auth-expired", provider: run.provider };
|
|
1152
|
+
}
|
|
1129
1153
|
/** Untrusted text on its way into a subject line: one line, bounded. */
|
|
1130
1154
|
function oneLine(text, cap) {
|
|
1131
1155
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
@@ -1479,6 +1503,8 @@ export function finalizePlanFailureFenced(store, args) {
|
|
|
1479
1503
|
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1480
1504
|
return refusal(db, leaseId);
|
|
1481
1505
|
}
|
|
1506
|
+
if (kind === "failure" && run.terminalClass === "auth-expired")
|
|
1507
|
+
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1482
1508
|
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-plan";
|
|
1483
1509
|
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1484
1510
|
if (kind === "malformed") {
|
|
@@ -1620,6 +1646,8 @@ export function finalizeScoutFailureFenced(store, args) {
|
|
|
1620
1646
|
store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
|
|
1621
1647
|
return refusal(db, leaseId);
|
|
1622
1648
|
}
|
|
1649
|
+
if (kind === "failure" && run.terminalClass === "auth-expired")
|
|
1650
|
+
return { ok: true, ...sealAuthExpired(store, run, taskId, now) };
|
|
1623
1651
|
const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-report";
|
|
1624
1652
|
store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
|
|
1625
1653
|
if (kind === "malformed") {
|
package/dist/control-ui.js
CHANGED
|
@@ -37,7 +37,7 @@ export function controlSetupHtml(data) {
|
|
|
37
37
|
`<p class="meta">Preparation applies to subsequent runs, including already approved tasks. Saving setup does not execute it.</p></details>` +
|
|
38
38
|
`<button type="submit">Review project setup</button></form>` +
|
|
39
39
|
`<section class="card"><h2>Agent instructions</h2>` +
|
|
40
|
-
(data.instructions.message ? `<p>${escape(data.instructions.message)}</p>` : data.instructions.installed ? `<p>
|
|
40
|
+
(data.instructions.message ? `<p>${escape(data.instructions.message)}</p>` : data.instructions.installed ? `<p>Toolroll instructions are installed.</p>` :
|
|
41
41
|
`<p>Give the agent the project's Toolroll handoff instructions.</p><form method="post" action="/control/instructions-preview">${hiddenFields({ csrf: data.csrf, repo: data.repo })}<button>Review instructions</button></form>`) +
|
|
42
42
|
`</section><p><a href="/fleet">Configure planning, repair, and review agents</a> · <a href="/tasks/new">Describe a task</a></p>`;
|
|
43
43
|
}
|
package/dist/daemon.d.ts
CHANGED
|
@@ -158,6 +158,15 @@ export declare function stopLaunchdService(definition: ServiceDefinition, run: S
|
|
|
158
158
|
* Installing the new one stops and removes it, so two watches never run.
|
|
159
159
|
*/
|
|
160
160
|
export declare function installedLegacyDaemon(args: Parameters<typeof planDaemon>[0]): DaemonPlan | null;
|
|
161
|
+
/**
|
|
162
|
+
* Install the watch, then retire its copy from before the rename. The new
|
|
163
|
+
* job goes in first: when its install fails the legacy watch is left
|
|
164
|
+
* running, so the repo is never left without one. Only once the new job is
|
|
165
|
+
* installed is the legacy one stopped and removed; the new one is then
|
|
166
|
+
* started again, since it may have stepped aside while the legacy watch
|
|
167
|
+
* still held the repo's lease.
|
|
168
|
+
*/
|
|
169
|
+
export declare function installReplacingLegacy(plan: DaemonPlan, token: string, legacy: DaemonPlan | null, run: SupervisorRunner): Promise<ServiceStart>;
|
|
161
170
|
/** Write the token 0600 and the unit, then hand the unit to the supervisor. */
|
|
162
171
|
export declare function installDaemon(plan: DaemonPlan, token: string, run: SupervisorRunner): Promise<ServiceStart>;
|
|
163
172
|
export declare function uninstallDaemon(plan: DaemonPlan, run: SupervisorRunner): Promise<{
|
package/dist/daemon.js
CHANGED
|
@@ -404,6 +404,27 @@ export function installedLegacyDaemon(args) {
|
|
|
404
404
|
const legacy = planDaemon({ ...args, label: legacyLabelFor(args.repo) });
|
|
405
405
|
return "error" in legacy || !existsSync(legacy.unitPath) ? null : legacy;
|
|
406
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Install the watch, then retire its copy from before the rename. The new
|
|
409
|
+
* job goes in first: when its install fails the legacy watch is left
|
|
410
|
+
* running, so the repo is never left without one. Only once the new job is
|
|
411
|
+
* installed is the legacy one stopped and removed; the new one is then
|
|
412
|
+
* started again, since it may have stepped aside while the legacy watch
|
|
413
|
+
* still held the repo's lease.
|
|
414
|
+
*/
|
|
415
|
+
export async function installReplacingLegacy(plan, token, legacy, run) {
|
|
416
|
+
const installed = await installDaemon(plan, token, run);
|
|
417
|
+
if (!installed.ok || legacy === null)
|
|
418
|
+
return installed;
|
|
419
|
+
try {
|
|
420
|
+
await uninstallDaemon(legacy, run);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
return { ok: false, message: `installed ${plan.label}, but could not stop ${legacy.label}, the same watch under its older name: ${error.message}` };
|
|
424
|
+
}
|
|
425
|
+
const restarted = await installDaemon(plan, token, run);
|
|
426
|
+
return restarted.ok ? installed : restarted;
|
|
427
|
+
}
|
|
407
428
|
/** Write the token 0600 and the unit, then hand the unit to the supervisor. */
|
|
408
429
|
export async function installDaemon(plan, token, run) {
|
|
409
430
|
writeFileSync(plan.tokenFile, `${token}\n`, { mode: 0o600 });
|
package/dist/decision.d.ts
CHANGED
|
@@ -117,4 +117,10 @@ export declare function parseHandoff(raw: string): {
|
|
|
117
117
|
* then the one instruction. Written for an agent that already holds the
|
|
118
118
|
* context — it needs the list of what failed, not the theory of why.
|
|
119
119
|
*/
|
|
120
|
+
/**
|
|
121
|
+
* Every unattended agent runs headless (run 2085): its turn ending IS the
|
|
122
|
+
* process exiting, so a command left in the background, a wakeup or a loop
|
|
123
|
+
* never gets a second turn — the attempt just ends without its handoff.
|
|
124
|
+
*/
|
|
125
|
+
export declare const HEADLESS_RULE: readonly string[];
|
|
120
126
|
export declare function repairPrompt(problems: readonly Problem[], mailbox: string): string;
|
package/dist/decision.js
CHANGED
|
@@ -374,6 +374,17 @@ export function parseHandoff(raw) {
|
|
|
374
374
|
* then the one instruction. Written for an agent that already holds the
|
|
375
375
|
* context — it needs the list of what failed, not the theory of why.
|
|
376
376
|
*/
|
|
377
|
+
/**
|
|
378
|
+
* Every unattended agent runs headless (run 2085): its turn ending IS the
|
|
379
|
+
* process exiting, so a command left in the background, a wakeup or a loop
|
|
380
|
+
* never gets a second turn — the attempt just ends without its handoff.
|
|
381
|
+
*/
|
|
382
|
+
export const HEADLESS_RULE = [
|
|
383
|
+
"- You run headless: run every command in the foreground and wait for it,",
|
|
384
|
+
" however long it takes. Never background a command to wait on it, and",
|
|
385
|
+
" never schedule a wakeup, cron job, monitor or loop. Your turn ending",
|
|
386
|
+
" ends the run, so write the handoff before you stop.",
|
|
387
|
+
];
|
|
377
388
|
export function repairPrompt(problems, mailbox) {
|
|
378
389
|
return [
|
|
379
390
|
`Your parked decision in ${mailbox} failed validation:`,
|
|
@@ -384,5 +395,6 @@ export function repairPrompt(problems, mailbox) {
|
|
|
384
395
|
"JSON: { urgency: \"blocking\", recap, question, options: [{ id, label,",
|
|
385
396
|
"consequence, reversible }], recommendation } — at least two options, every",
|
|
386
397
|
"option's reversible stated explicitly, recommendation naming an option id.",
|
|
398
|
+
...HEADLESS_RULE,
|
|
387
399
|
].join("\n");
|
|
388
400
|
}
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -65,6 +65,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
65
65
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
66
66
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
67
67
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
68
|
+
version?: never;
|
|
68
69
|
active: boolean;
|
|
69
70
|
phase: string;
|
|
70
71
|
detail: string;
|
|
@@ -78,7 +79,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
78
79
|
wasRunning?: never;
|
|
79
80
|
backupPath?: never;
|
|
80
81
|
updateId?: never;
|
|
81
|
-
version?: never;
|
|
82
82
|
buildId?: never;
|
|
83
83
|
workDir?: never;
|
|
84
84
|
} | {
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -37,7 +37,9 @@ export type DispatchDiagnosisCode = "updating" | "complete" | "needs-verificatio
|
|
|
37
37
|
/** v50: every root review attempt ended without a review; nothing retries. */
|
|
38
38
|
| "review-exhausted" | "cancelled" | "failed" | "running" | "vanished-run" | "retry-scheduled" | "waiting-decision" | "waiting-incident" | "held"
|
|
39
39
|
/** v52: an operator stopped one exact attempt; only resuming it lifts the pause. */
|
|
40
|
-
| "stopped" | "waiting-dependency" | "terminal-dependency" | "needs-project" | "needs-scope" | "needs-agent-profile" | "needs-approval" | "missing-requirement" | "no-worker-registered" | "no-worker-online" | "worker-at-capacity" | "provider-quota"
|
|
40
|
+
| "stopped" | "waiting-dependency" | "terminal-dependency" | "needs-project" | "needs-scope" | "needs-agent-profile" | "needs-approval" | "missing-requirement" | "no-worker-registered" | "no-worker-online" | "worker-at-capacity" | "provider-quota"
|
|
41
|
+
/** The provider's sign-in stopped working; its dispatch is paused. */
|
|
42
|
+
| "signed-out" | "planning-ready" | "planner-source" | "scouting-ready" | "ready";
|
|
41
43
|
export type DispatchDiagnosis = {
|
|
42
44
|
/** The four-state Never Stuck contract. */
|
|
43
45
|
condition: "running" | "retrying" | "waiting" | "terminal";
|
package/dist/dispatch.js
CHANGED
|
@@ -10,6 +10,7 @@ import { isAlive } from "./runner.js";
|
|
|
10
10
|
import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
|
|
11
11
|
import { approvalOf } from "./scope.js";
|
|
12
12
|
import { plannerSourceProblemOf } from "./planner-source.js";
|
|
13
|
+
import { authPauseOf, authWaitOf, providerName, signInReason } from "./provider-auth.js";
|
|
13
14
|
import { REVIEW_TOKENS, resultStatusOf, reviewFactsOf } from "./workspace-ui.js";
|
|
14
15
|
import { BUILT_IN, parseCapabilityKey } from "./store.js";
|
|
15
16
|
export const DEFAULT_MAX_OPEN_DECISIONS = 5;
|
|
@@ -222,6 +223,11 @@ export function diagnoseTaskDispatch(store, taskId, now) {
|
|
|
222
223
|
const local = taskReadinessBlocker(store, ref.id, now);
|
|
223
224
|
if (local?.code === "hold") {
|
|
224
225
|
if (local.ownerKind === "backoff") {
|
|
226
|
+
// Run 2085: an attempt that stopped before its handoff says so plainly.
|
|
227
|
+
const last = store.runsFor(ref.id).find(one => one.role === "builder" && one.finishedAt !== null);
|
|
228
|
+
const when = local.until === null ? "" : ` The next attempt is eligible at ${local.until}.`;
|
|
229
|
+
if (last?.reason === "no-handoff")
|
|
230
|
+
return answer("retry-scheduled", "retrying", "Retry scheduled", `The agent stopped before handing off; its work was kept and it is being resumed.${when}`, { nextAt: local.until });
|
|
225
231
|
return answer("retry-scheduled", "retrying", "Retry scheduled", local.until === null ? "The failed attempt is backing off before retrying." : `The next attempt is eligible at ${local.until}.`, { nextAt: local.until });
|
|
226
232
|
}
|
|
227
233
|
if (local.ownerKind === "decision")
|
|
@@ -281,6 +287,13 @@ export function diagnoseTaskDispatch(store, taskId, now) {
|
|
|
281
287
|
return answer("provider-quota", "retrying", "Provider quota exhausted", nextAt === null ? "The provider has not supplied a reset time; this needs operator attention." : `The next recorded reset is ${nextAt}.`, { action: nextAt === null ? "select-agent" : null, nextAt, role });
|
|
282
288
|
}
|
|
283
289
|
}
|
|
290
|
+
// The sign-in pause: the task's provider stopped working; nothing it would
|
|
291
|
+
// run starts until someone signs in (or a run or check on it works again).
|
|
292
|
+
const provider = profile?.provider ?? (role === "planner" ? ref.planProvider : null) ?? null;
|
|
293
|
+
const signedOut = authWaitOf(store, ref.id) ?? (provider === null ? null : authPauseOf(store, provider));
|
|
294
|
+
if (signedOut !== null) {
|
|
295
|
+
return answer("signed-out", "waiting", signInReason(signedOut), `This task starts again on its own once ${providerName(signedOut.provider)} works.`, { role });
|
|
296
|
+
}
|
|
284
297
|
const openDecisions = store.countUnanswered();
|
|
285
298
|
if (openDecisions >= DEFAULT_MAX_OPEN_DECISIONS && (role === "planner" || ref.parkRate > 0)) {
|
|
286
299
|
return answer("waiting-decision", "waiting", "Decision queue is full", `${openDecisions} decisions already wait; answer some before this task may add another.`, { action: "answer-decision", role });
|