toolroll 0.9.61 → 0.9.62

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/dist/store.js CHANGED
@@ -355,6 +355,22 @@ CREATE TABLE IF NOT EXISTS run_activity (
355
355
  at TEXT NOT NULL
356
356
  );
357
357
  `;
358
+ /** A task branch moved to current main before an attempt (fresh base; v122, a new table: additive only): the branch
359
+ * had no commits of its own and no changes, so it started over from `to_rev`. `after_run` is the newest run id when it
360
+ * moved: the branch's whole-task diff runs from the first builder base recorded after it (firstBuilderBase), never from
361
+ * the older main it left. */
362
+ export const BRANCH_BASE_MOVE_SCHEMA = `
363
+ CREATE TABLE IF NOT EXISTS branch_base_move (
364
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
365
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
366
+ branch TEXT NOT NULL,
367
+ from_rev TEXT NOT NULL,
368
+ to_rev TEXT NOT NULL,
369
+ after_run INTEGER NOT NULL,
370
+ moved_at TEXT NOT NULL
371
+ );
372
+ CREATE INDEX IF NOT EXISTS branch_base_move_branch ON branch_base_move (task_ref, branch, id);
373
+ `;
358
374
  /** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
359
375
  * stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
360
376
  * request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
@@ -605,7 +621,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
605
621
  // v121 adds, and changes no saved row: the Agents board (run.attempt_key; agent_turn, agent_now, agent_question,
606
622
  // agent_seen, agent_view), live steering (a note's live identity and sends, a run's stdin session; run_pause and
607
623
  // run_resume) and landed work's teardown journal (landed_cleanup, landed_cleanup_item).
608
- export const SCHEMA_VERSION = 121;
624
+ // v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
625
+ // remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
626
+ // fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
627
+ export const SCHEMA_VERSION = 122;
609
628
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
610
629
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
611
630
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -730,9 +749,11 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
730
749
  * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
731
750
  * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
732
751
  * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
733
- * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way.
752
+ * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
753
+ * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
754
+ * same way.
734
755
  */
735
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121]);
756
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]);
736
757
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
737
758
  export const V119_ADDED_COLUMNS = Object.freeze({
738
759
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -778,6 +799,11 @@ export const V121_ADDED_COLUMNS = Object.freeze({
778
799
  task_steer: LIVE_STEER_COLUMNS.task_steer,
779
800
  run: { ...LIVE_STEER_COLUMNS.run, attempt_key: { definition: "TEXT", saved: "NULL" } },
780
801
  });
802
+ /** v122: the column it adds, with the value every row saved before it reads: the remote URL an owner's project add
803
+ * recorded (projects added before it read none, until `repos add <project> --remote <url>` records one). */
804
+ export const V122_ADDED_COLUMNS = Object.freeze({
805
+ project: { remote_url: { definition: "TEXT", saved: "NULL" } },
806
+ });
781
807
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
782
808
  export function updateSafeSchema(version) {
783
809
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -3508,6 +3534,8 @@ function initializeStore(db, file) {
3508
3534
  db.exec(CHAT_APPROVAL_SCHEMA);
3509
3535
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3510
3536
  db.exec(RUN_ACTIVITY_SCHEMA);
3537
+ // v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
3538
+ db.exec(BRANCH_BASE_MOVE_SCHEMA);
3511
3539
  // v121: the Agents board: each run's attempt key (V121_ADDED_COLUMNS, below), its turn history and current snapshot,
3512
3540
  // the agent's open questions, who has seen a finished run and who is watching one now. Older runs read null keys.
3513
3541
  db.exec(AGENT_BOARD_SCHEMA);
@@ -3540,6 +3568,12 @@ function initializeStore(db, file) {
3540
3568
  db.exec(STORAGE_SWEEP_SCHEMA);
3541
3569
  // v121: landed work's teardown journal (landed-cleanup.ts): new tables.
3542
3570
  db.exec(LANDED_CLEANUP_SCHEMA);
3571
+ // v122: the remote URL an owner's project add recorded (V122_ADDED_COLUMNS; additive only, saved projects read none):
3572
+ // automatic completion trusts only that remote's main (remote-main.ts), never the shared repository's config.
3573
+ for (const [table, columns] of Object.entries(V122_ADDED_COLUMNS)) {
3574
+ for (const [column, { definition }] of Object.entries(columns))
3575
+ addColumn(db, table, column, definition);
3576
+ }
3543
3577
  db.exec(BACKUP_SCHEMA);
3544
3578
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3545
3579
  db.exec(POLICY_SCHEMA);
@@ -3712,6 +3746,8 @@ function createBaseline(db, file) {
3712
3746
  }
3713
3747
  db.exec(BASELINE_SCHEMA);
3714
3748
  db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(BASELINE_SCHEMA_VERSION);
3749
+ const installedAt = new Date().toISOString();
3750
+ db.prepare("INSERT INTO installation_fact (key, value, created_at) VALUES ('installed-at', ?, ?)").run(installedAt, installedAt);
3715
3751
  db.exec("COMMIT");
3716
3752
  }
3717
3753
  catch (error) {
@@ -12140,13 +12176,46 @@ export class Store {
12140
12176
  * legacy first-attempt behavior, unchanged byte-for-byte.
12141
12177
  */
12142
12178
  firstBuilderBase(taskRef, branch) {
12179
+ // A branch moved to current main (fresh base) had no commits of its own:
12180
+ // its origin is the first base recorded after the move, not the older main.
12143
12181
  const row = this.db
12144
12182
  .prepare(`SELECT base_revision FROM run
12145
12183
  WHERE task_ref = ? AND branch = ? AND role = 'builder' AND base_revision IS NOT NULL
12184
+ AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
12146
12185
  ORDER BY id ASC LIMIT 1`)
12147
- .get(taskRef, branch);
12186
+ .get(taskRef, branch, taskRef, branch);
12187
+ return row === undefined ? null : row.base_revision;
12188
+ }
12189
+ /** Where a task branch's attempts start now: a builder branch's pinned base, or the newest base any other attempt on
12190
+ * it recorded (a planner never commits). Null before any attempt on the branch recorded one, or after a move to
12191
+ * current main until the next attempt records its base. */
12192
+ recordedBranchBase(taskRef, branch) {
12193
+ const pinned = this.firstBuilderBase(taskRef, branch);
12194
+ if (pinned !== null)
12195
+ return pinned;
12196
+ const row = this.db
12197
+ .prepare(`SELECT base_revision FROM run
12198
+ WHERE task_ref = ? AND branch = ? AND role <> 'builder' AND base_revision IS NOT NULL
12199
+ AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
12200
+ ORDER BY id DESC LIMIT 1`)
12201
+ .get(taskRef, branch, taskRef, branch);
12148
12202
  return row === undefined ? null : row.base_revision;
12149
12203
  }
12204
+ /** Record a task branch's move to current main, before git moves it: a move that then fails leaves the next
12205
+ * attempt's own base (still the old one) as the branch's origin, which is exactly right. */
12206
+ recordBranchBaseMove(taskRef, branch, from, to, now) {
12207
+ this.db
12208
+ .prepare(`INSERT INTO branch_base_move (task_ref, branch, from_rev, to_rev, after_run, moved_at)
12209
+ VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(id), 0) FROM run), ?)`)
12210
+ .run(taskRef, branch, from, to, now.toISOString());
12211
+ }
12212
+ /** The newest move of a task branch to current main, or null. */
12213
+ latestBranchBaseMove(taskRef, branch) {
12214
+ const row = this.db
12215
+ .prepare("SELECT from_rev, to_rev, after_run, moved_at FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1")
12216
+ .get(taskRef, branch);
12217
+ return row === undefined ? null : { from: String(row["from_rev"]), to: String(row["to_rev"]), afterRun: Number(row["after_run"]), movedAt: String(row["moved_at"]) };
12218
+ }
12150
12219
  finishRun(id, result) {
12151
12220
  this.transact(() => {
12152
12221
  // Read before the write (Telegram task updates): only the OPEN → closed
@@ -15594,17 +15663,38 @@ export class Store {
15594
15663
  // ---- projects ------------------------------------------------------------
15595
15664
  /** Remember a project was opened. Upsert keeps added_at; recency always moves.
15596
15665
  * A project added for the first time starts on Quick checks. */
15597
- upsertProject(path, name, now) {
15666
+ upsertProject(path, name, now, admission) {
15598
15667
  this.transact(() => {
15599
15668
  const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
15600
15669
  this.db
15601
15670
  .prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
15602
15671
  ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
15603
15672
  .run(path, name, now.toISOString(), now.toISOString());
15673
+ // Admission owns remote changes; ordinary openings preserve the saved value.
15674
+ if (admission !== undefined)
15675
+ this.db.prepare("UPDATE project SET remote_url = ? WHERE path = ?").run(admission.remoteUrl, path);
15604
15676
  if (added)
15605
15677
  stampNewProjectLevel(this, path, now);
15606
15678
  });
15607
15679
  }
15680
+ /** Includes removed/legacy projects, so deleting a row never makes build-writable config authoritative again. */
15681
+ projectAdmissionKnown(path) {
15682
+ return this.db.prepare(`SELECT 1 FROM project WHERE path = ?
15683
+ UNION ALL SELECT 1 FROM action_ledger WHERE repo = ?
15684
+ UNION ALL SELECT 1 FROM task_ref WHERE repo = ?
15685
+ UNION ALL SELECT 1 FROM worktree WHERE repo = ? LIMIT 1`).get(path, path, path, path) !== undefined;
15686
+ }
15687
+ /** Full structured URL audit, without the generic ledger's display-text truncation. */
15688
+ recordProjectRemote(actor, repo, previous, remoteUrl, now) {
15689
+ this.db.prepare(`INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail)
15690
+ VALUES (?,?,?,NULL,NULL,'project remote recorded','recorded','access',?)`)
15691
+ .run(now.toISOString(), actor, repo, JSON.stringify({ previousRemote: previous, remoteUrl }));
15692
+ }
15693
+ /** The remote URL captured on admission or explicitly supplied by the owner; null when none was (or the project was removed). */
15694
+ projectRemote(path) {
15695
+ const url = this.db.prepare("SELECT remote_url FROM project WHERE path = ?").get(path)?.["remote_url"];
15696
+ return typeof url === "string" && url !== "" ? url : null;
15697
+ }
15608
15698
  /** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
15609
15699
  forgetProject(path) {
15610
15700
  this.db.prepare("DELETE FROM project WHERE path = ?").run(path);
@@ -15619,6 +15709,7 @@ export class Store {
15619
15709
  name: String(row["name"]),
15620
15710
  addedAt: String(row["added_at"]),
15621
15711
  lastOpenedAt: String(row["last_opened_at"]),
15712
+ remoteUrl: typeof row["remote_url"] === "string" ? row["remote_url"] : null,
15622
15713
  }));
15623
15714
  }
15624
15715
  /**
package/dist/surface.js CHANGED
@@ -67,9 +67,11 @@ const GUIDE_ROWS = [
67
67
  // D5: the central team service (connect, the central lead verbs, conversation …) and native coding sessions are
68
68
  // deprecated: they still run this release, with a warning, but are no longer declared here.
69
69
  operator("chat", "talk to your lead: one conversation across every project; --say sends one message; --follow enables crew updates and --no-follow pauses them; proposals keep their approval requirements"),
70
- { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
70
+ // Unkeyed: --remote with a different URL replaces the project's trusted remote (recorded in the action ledger).
71
+ { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token; --remote records or replaces its trusted remote", audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
71
72
  positionals: [{ name: "path", required: false, meaning: "the repository (default: the one you are in)" }],
72
- flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
73
+ flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" },
74
+ { name: "remote", takesValue: true, meaning: "an HTTPS or SSH URL without credentials whose main may complete landed work; replaces a different recorded one (owner or an instance operator's lead only)" }],
73
75
  notableReasons: ["not-git", "unavailable", "refused", "locked"] },
74
76
  { invocation: "repos remove", synopsis: "remove a project from the console and the builder; its tasks and results stay saved", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
75
77
  positionals: [{ name: "path", required: true, meaning: "the repository" }],
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a task's work starts, against current main (fresh base): one view for
3
+ * the task page and `task show`. The base is saved run lineage — a builder
4
+ * branch's pinned origin, or the newest plan's base — and the behind count is
5
+ * read from Git now. Reads only.
6
+ */
7
+ import type { Store } from "./store.js";
8
+ export type TaskBaseView = {
9
+ /** The base commit, in full. */
10
+ base: string;
11
+ branch: string;
12
+ /** The project's main: its checked-out branch, as a dispatch pass resolves it. Null when Git could not say. */
13
+ main: string | null;
14
+ /** Commits on main the base does not have; null when Git could not say. */
15
+ behind: number | null;
16
+ /** One short line: `Base 9d087efe · Behind main by 2 commits`. */
17
+ words: string;
18
+ };
19
+ /** A synchronous, read-only Git call in `cwd`: its trimmed output, or null on any failure. */
20
+ export type GitRead = (cwd: string, args: readonly string[]) => string | null;
21
+ export declare const readGit: GitRead;
22
+ /** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
23
+ export declare function baseStandingWords(view: Pick<TaskBaseView, "base" | "main" | "behind">): string;
24
+ /**
25
+ * The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
26
+ * revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
27
+ * candidate was proved against.
28
+ */
29
+ export declare function freshBaseFor(store: Store, taskId: string, branch: string, main: string): {
30
+ freshBase: {
31
+ main: string;
32
+ recordedBase: string | null;
33
+ };
34
+ } | Record<string, never>;
35
+ export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a task's work starts, against current main (fresh base): one view for
3
+ * the task page and `task show`. The base is saved run lineage — a builder
4
+ * branch's pinned origin, or the newest plan's base — and the behind count is
5
+ * read from Git now. Reads only.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ export const readGit = (cwd, args) => {
9
+ try {
10
+ return execFileSync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 65_536 }).trim();
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ };
16
+ const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
17
+ /** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
18
+ export function baseStandingWords(view) {
19
+ const short = view.base.slice(0, 8);
20
+ if (view.main === null || view.behind === null)
21
+ return short;
22
+ return view.behind === 0 ? `${short} · Up to date with ${view.main}` : `${short} · Behind ${view.main} by ${commits(view.behind)}`;
23
+ }
24
+ /**
25
+ * The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
26
+ * revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
27
+ * candidate was proved against.
28
+ */
29
+ export function freshBaseFor(store, taskId, branch, main) {
30
+ const ref = store.lookupRef(taskId);
31
+ if (ref === null || ref.revisionOf !== null || store.getScope(taskId)?.candidate != null || store.filedViaOf(taskId)?.startsWith("coding:") === true)
32
+ return {};
33
+ return { freshBase: { main, recordedBase: store.recordedBranchBase(ref.id, branch) } };
34
+ }
35
+ export function taskBaseOf(store, taskId, git = readGit) {
36
+ const ref = store.lookupRef(taskId);
37
+ if (ref === null || ref.repo === null)
38
+ return null;
39
+ const latest = store.runsFor(ref.id).find(one => one.role !== "reviewer" && one.branch !== null && one.baseRevision !== null);
40
+ if (latest === undefined || latest.branch === null || latest.baseRevision === null)
41
+ return null;
42
+ const branch = latest.branch;
43
+ // A move to current main newer than the latest attempt is where the next one starts.
44
+ const move = store.latestBranchBaseMove(ref.id, branch);
45
+ const base = move !== null && move.afterRun >= latest.id ? move.to : store.recordedBranchBase(ref.id, branch) ?? latest.baseRevision;
46
+ const main = git(ref.repo, ["symbolic-ref", "--short", "-q", "HEAD"]) || null;
47
+ const counted = main === null ? null : git(ref.repo, ["rev-list", "--count", `${base}..refs/heads/${main}`]);
48
+ const behind = counted === null || !/^\d+$/.test(counted) ? null : Number(counted);
49
+ const view = { base, branch, main: behind === null ? null : main, behind };
50
+ return { ...view, words: `Base ${baseStandingWords(view)}` };
51
+ }
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
35
35
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
37
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS } from "./store.js";
38
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS } from "./store.js";
39
39
  import { isNewer, REGISTRY } from "./releases.js";
40
40
  import { markNeverIndex } from "./never-index.js";
41
41
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -399,7 +399,8 @@ const HISTORY_RULES = {
399
399
  // v120: a steering note's mailing and acknowledgement, and a run's steering folder, are new columns; every row
400
400
  // saved before them reads none.
401
401
  // v121: live steering's columns and a run's attempt key read none, and no sends, the same way.
402
- ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS),
402
+ // v122: a project's recorded remote reads none the same way.
403
+ ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS),
403
404
  };
404
405
  /** The tables a migration may remove whole (v115's removed features). */
405
406
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -52,6 +52,32 @@ export type LeaseRequest = {
52
52
  * stands, as its kept checkout would have been reused. Without it, `base` always starts a new branch (and an
53
53
  * existing one refuses). */
54
54
  reuseBranch?: boolean;
55
+ /**
56
+ * Start from current main (fresh base): a checkout still at its recorded base — no commits of its own — and with no
57
+ * changes moves, with its branch, to `main` before the attempt. Anything else is never moved; the result says how
58
+ * far behind main it is. Needs `taskRef`. `recordedBase` null means no attempt on the branch recorded one yet, and
59
+ * then only a HEAD that main already contains counts as having no commits of its own.
60
+ */
61
+ freshBase?: {
62
+ main: string;
63
+ recordedBase: string | null;
64
+ };
65
+ };
66
+ /** Where a checkout stands against current main once its lease is granted (fresh base). */
67
+ export type BaseStanding = {
68
+ /** HEAD as the attempt starts. */
69
+ head: string;
70
+ /** Current main's commit. */
71
+ main: string;
72
+ /** Commits on main that HEAD does not have. */
73
+ behind: number;
74
+ /** Set when this lease moved the checkout to main: where it was, and how far behind that was. */
75
+ moved: {
76
+ from: string;
77
+ behind: number;
78
+ } | null;
79
+ /** Why a checkout behind main stayed where it is. */
80
+ kept: "has commits" | "has changes" | "not on its branch" | null;
55
81
  };
56
82
  export type LeaseResult = {
57
83
  ok: true;
@@ -60,6 +86,8 @@ export type LeaseResult = {
60
86
  reclaimed?: string;
61
87
  resumedFromRun?: number;
62
88
  recoveryKind?: "completed" | "partial";
89
+ /** With `freshBase`: where the checkout stands against main (null when main could not be read). */
90
+ base?: BaseStanding | null;
63
91
  } | {
64
92
  ok: false;
65
93
  reason: LeaseFailure;
@@ -76,6 +104,13 @@ export type KeptWhy = "has changes" | "has commits" | "unreadable" | "git refuse
76
104
  * something is in there.
77
105
  */
78
106
  export declare const MARKER = ".standing-orders-lease";
107
+ /**
108
+ * The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
109
+ * is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
110
+ */
111
+ export declare const branchCreatedMessage: (branch: string) => string;
112
+ /** Scouts' disposable branches: never landed work. */
113
+ export declare const SCOUT_PREFIX = "toolroll-scout/";
79
114
  /** What a kept checkout drops once its run ends (only where .gitignore hides it): dependencies and build output. */
80
115
  export declare const SLIM_NAMES: readonly string[];
81
116
  /** Creating a worktree copies a tree; it is local work but not instant. */
@@ -157,6 +192,13 @@ export declare class WorktreePool {
157
192
  recordProviderOccupancy(path: string, runner: string, providerPid: number, leaseEpoch?: string | null): void;
158
193
  lease(request: LeaseRequest): Promise<LeaseResult>;
159
194
  private leaseNow;
195
+ /**
196
+ * Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
197
+ * branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
198
+ * untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
199
+ * again right before and after it, and any surprise fails the lease with the checkout left for a person.
200
+ */
201
+ private freshen;
160
202
  /**
161
203
  * A crash can strand either a finished handoff or a partial source draft.
162
204
  * The expired lease can no longer authorize a commit, so this never ingests
@@ -245,7 +287,8 @@ export declare class WorktreePool {
245
287
  /**
246
288
  * Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
247
289
  * let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
248
- * lease since keeps it) and `inspect` finds it clean. Only the working copy goes: its branch, and so every commit,
290
+ * lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
291
+ * made after that). Only the working copy goes: its branch, and so every commit,
249
292
  * stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
250
293
  */
251
294
  prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean | Promise<boolean>, max?: number): Promise<{
@@ -280,8 +323,17 @@ export declare class WorktreePool {
280
323
  ok: false;
281
324
  message: string;
282
325
  }>;
283
- /** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
326
+ /** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
327
+ * Only `discardChanges`, where losing them is what a person asked for. */
284
328
  private removeWorkingCopy;
329
+ /**
330
+ * Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
331
+ * reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
332
+ * removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
333
+ * git's check would count them.
334
+ */
335
+ private removeCleanCopy;
336
+ private forgetRemoved;
285
337
  /**
286
338
  * Whether anybody's work is in there. null means we could not tell, which is
287
339
  * neither clean nor dirty and must not be rounded to either.
package/dist/worktree.js CHANGED
@@ -40,6 +40,13 @@ import { markNeverIndex } from "./never-index.js";
40
40
  * something is in there.
41
41
  */
42
42
  export const MARKER = ".standing-orders-lease";
43
+ /**
44
+ * The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
45
+ * is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
46
+ */
47
+ export const branchCreatedMessage = (branch) => `Toolroll created branch ${branch}`;
48
+ /** Scouts' disposable branches: never landed work. */
49
+ export const SCOUT_PREFIX = "toolroll-scout/";
43
50
  /** Checkouts this process is leasing or removing right now: a lease and storage retention never overlap on one. */
44
51
  const busy = new Map();
45
52
  // Outside the checkout so removal cannot erase the reservation before git and the row update finish.
@@ -407,17 +414,34 @@ export class WorktreePool {
407
414
  catch { /* git reports it */ }
408
415
  markNeverIndex(this.options.root);
409
416
  markNeverIndex(dirname(path));
410
- const add = await this.git(request.repo, [
411
- "worktree",
412
- "add",
413
- ...(fromBase ? ["-b", request.branch] : []),
414
- path,
415
- ...(fromBase ? [request.base] : [request.branch]),
416
- ]);
417
+ // A new branch is created first, on its own, so its reflog's first entry says Toolroll made it and when.
418
+ let made = null;
419
+ if (fromBase) {
420
+ const base = await this.git(request.repo, ["rev-parse", "--verify", "--quiet", `${request.base}^{commit}`], READ_ONLY);
421
+ const oid = base.stdout.trim();
422
+ if (base.code !== 0 || !/^[0-9a-f]{40,64}$/.test(oid))
423
+ return { ok: false, reason: "git", message: `git couldn't find ${request.base}` };
424
+ const branch = await this.git(request.repo, ["update-ref", "--create-reflog", "-m", branchCreatedMessage(request.branch), `refs/heads/${request.branch}`, oid, "0".repeat(oid.length)]);
425
+ if (branch.code !== 0)
426
+ return { ok: false, reason: "git", message: firstLine(branch.stderr) };
427
+ made = oid;
428
+ }
429
+ const add = await this.git(request.repo, ["worktree", "add", path, request.branch]);
417
430
  if (add.code !== 0) {
431
+ if (made !== null)
432
+ await this.git(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
418
433
  return { ok: false, reason: "git", message: firstLine(add.stderr) };
419
434
  }
420
435
  }
436
+ // Fresh base, inside the same reservation as everything above: nothing else leases, prunes or slims this
437
+ // checkout between the inspection and the move.
438
+ let base;
439
+ if (request.freshBase !== undefined && request.taskRef !== undefined) {
440
+ const fresh = await this.freshen(path, request.branch, request.taskRef, request.freshBase, request.now);
441
+ if (!fresh.ok)
442
+ return { ok: false, reason: "git", message: fresh.message };
443
+ base = fresh.standing;
444
+ }
421
445
  const row = {
422
446
  path,
423
447
  repo: request.repo,
@@ -453,7 +477,49 @@ export class WorktreePool {
453
477
  ...(reclaimed === undefined ? {} : { reclaimed }),
454
478
  ...(resumedFromRun === undefined ? {} : { resumedFromRun }),
455
479
  ...(recoveryKind === undefined ? {} : { recoveryKind }),
480
+ ...(base === undefined ? {} : { base }),
481
+ };
482
+ }
483
+ /**
484
+ * Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
485
+ * branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
486
+ * untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
487
+ * again right before and after it, and any surprise fails the lease with the checkout left for a person.
488
+ */
489
+ async freshen(path, branch, taskRef, fresh, now) {
490
+ const read = async (args) => {
491
+ const out = await this.runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
492
+ return out.code === 0 ? out.stdout.trim() : null;
456
493
  };
494
+ const main = await read(["rev-parse", "--verify", "--quiet", `${fresh.main}^{commit}`]);
495
+ const head = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
496
+ if (main === null || head === null)
497
+ return { ok: true, standing: null };
498
+ const count = await read(["rev-list", "--count", `${head}..${main}`]);
499
+ const behind = count === null ? NaN : Number(count);
500
+ if (!Number.isSafeInteger(behind))
501
+ return { ok: true, standing: null };
502
+ const stay = (kept) => ({ ok: true, standing: { head, main, behind, moved: null, kept } });
503
+ if (behind === 0)
504
+ return stay(null);
505
+ if (await read(["symbolic-ref", "-q", "HEAD"]) !== `refs/heads/${branch}`)
506
+ return stay("not on its branch");
507
+ if (fresh.recordedBase !== null && fresh.recordedBase !== head)
508
+ return stay("has commits");
509
+ const contained = await this.runner(GIT, [...READ_ONLY, "merge-base", "--is-ancestor", head, main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
510
+ if (contained.code !== 0)
511
+ return stay("has commits");
512
+ if (await this.isDirty(path) !== false)
513
+ return stay("has changes");
514
+ // Recorded before git moves it: a move that then fails leaves the next attempt's own base as the branch's origin.
515
+ this.store.recordBranchBaseMove(taskRef, branch, head, main, now);
516
+ const moved = await this.runner(GIT, ["reset", "--keep", main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
517
+ const after = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
518
+ const branchNow = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
519
+ if (moved.code !== 0 || after !== main || branchNow !== main || await this.isDirty(path) !== false) {
520
+ return { ok: false, message: `${path} could not be moved to current main (${firstLine(moved.stderr) || "it changed during the move"}) — leaving it for a person` };
521
+ }
522
+ return { ok: true, standing: { head: main, main, behind: 0, moved: { from: head, behind }, kept: null } };
457
523
  }
458
524
  /**
459
525
  * A crash can strand either a finished handoff or a partial source draft.
@@ -739,7 +805,8 @@ export class WorktreePool {
739
805
  /**
740
806
  * Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
741
807
  * let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
742
- * lease since keeps it) and `inspect` finds it clean. Only the working copy goes: its branch, and so every commit,
808
+ * lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
809
+ * made after that). Only the working copy goes: its branch, and so every commit,
743
810
  * stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
744
811
  */
745
812
  async prune(rows, wanted, max = Infinity) {
@@ -776,9 +843,9 @@ export class WorktreePool {
776
843
  const allowed = wanted(now, true);
777
844
  if (!(typeof allowed === "boolean" ? allowed : await allowed))
778
845
  continue;
779
- const gone = await this.removeWorkingCopy(row);
780
- if (!gone) {
781
- kept.push({ path: row.path, why: "git refused" });
846
+ const gone = await this.removeCleanCopy(row);
847
+ if (gone !== "removed") {
848
+ kept.push({ path: row.path, why: gone });
782
849
  continue;
783
850
  }
784
851
  removed.push(row);
@@ -871,7 +938,8 @@ export class WorktreePool {
871
938
  busy.delete(path);
872
939
  }
873
940
  }
874
- /** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
941
+ /** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
942
+ * Only `discardChanges`, where losing them is what a person asked for. */
875
943
  async removeWorkingCopy(row) {
876
944
  // Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
877
945
  const note = join(row.path, MARKER);
@@ -886,12 +954,39 @@ export class WorktreePool {
886
954
  rmSync(note, { force: true });
887
955
  return false;
888
956
  }
957
+ this.forgetRemoved(row);
958
+ return true;
959
+ }
960
+ /**
961
+ * Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
962
+ * reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
963
+ * removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
964
+ * git's check would count them.
965
+ */
966
+ async removeCleanCopy(row) {
967
+ const found = await this.inspect(row.path);
968
+ if (found !== "clean")
969
+ return found;
970
+ const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
971
+ if (status.code !== 0)
972
+ return "unreadable";
973
+ for (const line of status.stdout.split("\n")) {
974
+ const name = line.startsWith("?? ") ? line.slice(3).trim() : "";
975
+ if (name === MARKER || (name !== "" && looksLikeProtocolFile(name)))
976
+ rmSync(join(row.path, name), { recursive: true, force: true });
977
+ }
978
+ const gone = await this.git(row.repo, ["worktree", "remove", row.path]);
979
+ if (gone.code !== 0)
980
+ return (await this.isDirty(row.path)) === false ? "git refused" : "has changes";
981
+ this.forgetRemoved(row);
982
+ return "removed";
983
+ }
984
+ forgetRemoved(row) {
889
985
  this.store.transact(() => {
890
986
  const still = this.store.getWorktree(row.path);
891
987
  if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
892
988
  this.store.forgetWorktree(row.path);
893
989
  });
894
- return true;
895
990
  }
896
991
  /**
897
992
  * Whether anybody's work is in there. null means we could not tell, which is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.61",
3
+ "version": "0.9.62",
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"