tickmarkr 2.5.3 → 2.5.5
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/adapters/registry.js +6 -1
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.d.ts +1 -0
- package/dist/cli/commands/approve.js +54 -6
- package/dist/cli/commands/doctor.d.ts +4 -0
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +195 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/plan.js +144 -3
- package/dist/cli/commands/resume.js +6 -2
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/verify.js +1 -1
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/compile/collateral.d.ts +2 -0
- package/dist/compile/collateral.js +50 -9
- package/dist/compile/ownership.d.ts +7 -0
- package/dist/compile/ownership.js +59 -22
- package/dist/config/config.d.ts +23 -8
- package/dist/config/config.js +42 -28
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +68 -11
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/herdr.d.ts +5 -0
- package/dist/drivers/herdr.js +14 -3
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +38 -10
- package/dist/drivers/orca.d.ts +99 -10
- package/dist/drivers/orca.js +586 -97
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/baseline.d.ts +3 -0
- package/dist/gates/baseline.js +2 -1
- package/dist/gates/cache.d.ts +100 -0
- package/dist/gates/cache.js +389 -0
- package/dist/gates/review.d.ts +35 -2
- package/dist/gates/review.js +64 -16
- package/dist/gates/run-gates.d.ts +5 -0
- package/dist/gates/run-gates.js +134 -18
- package/dist/gates/test-manifest.d.ts +99 -0
- package/dist/gates/test-manifest.js +389 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +49 -0
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.d.ts +13 -0
- package/dist/route/router.js +95 -17
- package/dist/run/daemon.d.ts +13 -0
- package/dist/run/daemon.js +398 -96
- package/dist/run/git.d.ts +35 -1
- package/dist/run/git.js +111 -10
- package/dist/run/journal.d.ts +14 -2
- package/dist/run/journal.js +78 -10
- package/dist/run/lease.d.ts +14 -0
- package/dist/run/lease.js +87 -0
- package/dist/run/merge.d.ts +2 -0
- package/dist/run/merge.js +91 -3
- package/dist/run/operator-state.d.ts +11 -0
- package/dist/run/operator-state.js +17 -3
- package/dist/tui/cockpit/board.d.ts +96 -0
- package/dist/tui/cockpit/board.js +346 -0
- package/dist/tui/cockpit/decision-actions.js +2 -0
- package/dist/tui/cockpit/layout.d.ts +5 -1
- package/dist/tui/cockpit/layout.js +8 -3
- package/dist/tui/cockpit/live-runtime.js +83 -31
- package/dist/tui/cockpit/run-view.d.ts +7 -5
- package/dist/tui/cockpit/run-view.js +12 -11
- package/dist/tui/ink/fleet-app.d.ts +41 -27
- package/dist/tui/ink/fleet-app.js +204 -31
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +180 -99
package/dist/drivers/orca.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
4
|
import { shq } from "../adapters/types.js";
|
|
4
5
|
import { createWorktree, sh } from "../run/git.js";
|
|
5
|
-
import { Journal } from "../run/journal.js";
|
|
6
|
+
import { Journal, parseRunId } from "../run/journal.js";
|
|
7
|
+
import { stateDirName } from "../graph/graph.js";
|
|
8
|
+
import { readWatchBoard, requestWatchBoardStop, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
|
|
6
9
|
import { MAX_BUF } from "./subprocess.js";
|
|
7
10
|
import { formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
|
|
8
11
|
// Orca (onorca.dev) as a third execution surface beside herdr and subprocess. tickmarkr keeps
|
|
@@ -30,11 +33,16 @@ import { formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
|
|
|
30
33
|
// focused — a driver that lets the app pick has given away the isolation the worktree exists
|
|
31
34
|
// for, so every call this driver makes names `path:` and verifies what came back (T2);
|
|
32
35
|
// - `terminal list`'s `--worktree` is OPTIONAL (same help): the reconcile sweep omits it, because
|
|
33
|
-
// an older run's leftover sits in a checkout this run never knew (T2)
|
|
36
|
+
// an older run's leftover sits in a checkout this run never knew (T2);
|
|
37
|
+
// - Orca tracks only the worktrees it created or the operator opened (1.4.200, OBS-1004): a task
|
|
38
|
+
// checkout the daemon adds under the clone is not a selector, `worktree current` from inside it
|
|
39
|
+
// answers the enclosing clone, and there is no adopt verb — so every terminal is created ON the
|
|
40
|
+
// tracked worktree with its command cd'ed into the checkout, and `worktree set` names the
|
|
41
|
+
// tracked worktree; identity stays the handle plus the owned tab title.
|
|
34
42
|
/** The response families the ONE shared envelope parser serves. There is no second JSON seam. */
|
|
35
43
|
export const ORCA_RESPONSE_FAMILIES = [
|
|
36
44
|
"status", "create", "list", "read", "send", "wait", "show", "close",
|
|
37
|
-
"worktree-current", "worktree-set", "hooks-status",
|
|
45
|
+
"worktree-current", "worktree-set", "hooks-status", "split",
|
|
38
46
|
];
|
|
39
47
|
export const ORCA_FIXTURE_VERSION = "1.4.195";
|
|
40
48
|
export const ORCA_CLI_COMMAND_ENV = "ORCA_CLI_COMMAND";
|
|
@@ -54,9 +62,6 @@ const PAGE_LINES = 500; // per-page ask; orca caps server-side and reports `limi
|
|
|
54
62
|
const LIST_LIMIT = 10000; // well past orca's own row default; `truncated` still decides (listAll)
|
|
55
63
|
const MAX_PAGES = 400; // runaway guard: a cursor that stops advancing ends the sweep, never loops
|
|
56
64
|
const POLL_MS = 200;
|
|
57
|
-
export const WORKTREE_ADOPTION_TIMEOUT_MS = 60_000;
|
|
58
|
-
const WORKTREE_ADOPTION_POLL_MS = 1_000;
|
|
59
|
-
const WORKTREE_ADOPTION_JOURNAL_MS = 2_000;
|
|
60
65
|
const NUDGE_ECHO_TIMEOUT_MS = 2_000;
|
|
61
66
|
/** A missing slot gets the same bounded chance to appear as a reaped shell gets to settle. */
|
|
62
67
|
export const PENDING_PROJECT_GRACE_MS = 2_000;
|
|
@@ -189,6 +194,67 @@ export function canonicalWorktreePath(path) {
|
|
|
189
194
|
function sameWorktree(reported, canonical) {
|
|
190
195
|
return reported !== undefined && canonicalWorktreePath(reported) === canonical;
|
|
191
196
|
}
|
|
197
|
+
/** OBS-1004: a reported (tracked) worktree encloses a checkout when it IS the checkout or a parent of it. */
|
|
198
|
+
function enclosesCheckout(reported, checkout) {
|
|
199
|
+
if (reported === undefined)
|
|
200
|
+
return false;
|
|
201
|
+
const tracked = canonicalWorktreePath(reported);
|
|
202
|
+
return tracked === checkout || checkout.startsWith(`${tracked}/`);
|
|
203
|
+
}
|
|
204
|
+
/** The proof line a worker terminal prints first: recovery and focus read it back (FX-N01). */
|
|
205
|
+
export const CHECKOUT_MARK = "TICKMARKR_CHECKOUT";
|
|
206
|
+
// FX-N05: the proof is a FRAMED value — `TICKMARKR_CHECKOUT <byteLength>:<utf8 bytes as hex>;` — so it
|
|
207
|
+
// carries no whitespace or quotes, survives renderer wrapping (hex rows re-join losslessly), and is
|
|
208
|
+
// either complete (length matches, terminator present) or nothing. A prefix of another checkout can
|
|
209
|
+
// never decode to this one; `A` versus `A B`, `…--T1` versus `…--T10` are different frames.
|
|
210
|
+
const CHECKOUT_FRAME_RE = /TICKMARKR_CHECKOUT (\d+):([0-9a-f]*)(;?)/g;
|
|
211
|
+
const PROOF_PAGES = 16; // pages read from the oldest cursor before the proof is declared absent
|
|
212
|
+
/** The exact bytes the create command prints as its first line. */
|
|
213
|
+
export function checkoutProofLine(checkout) {
|
|
214
|
+
const bytes = Buffer.from(checkout, "utf8");
|
|
215
|
+
return `${CHECKOUT_MARK} ${bytes.length}:${bytes.toString("hex")};`;
|
|
216
|
+
}
|
|
217
|
+
/** Every complete frame in a scrollback, decoded and canonicalized; whether an incomplete one was seen. */
|
|
218
|
+
export function checkoutFrames(text) {
|
|
219
|
+
const complete = [];
|
|
220
|
+
let incomplete = false;
|
|
221
|
+
// Rows are re-joined first: a wrapped frame is whole again, and joining can never complete a
|
|
222
|
+
// frame that was not printed whole — the declared length and the terminator decide.
|
|
223
|
+
for (const m of joinWrapped(text).matchAll(CHECKOUT_FRAME_RE)) {
|
|
224
|
+
const length = Number(m[1]);
|
|
225
|
+
const hex = m[2];
|
|
226
|
+
if (m[3] !== ";" || hex.length !== length * 2 || !Number.isInteger(length)) {
|
|
227
|
+
incomplete = true;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
complete.push(canonicalWorktreePath(Buffer.from(hex, "hex").toString("utf8")));
|
|
231
|
+
}
|
|
232
|
+
return { complete, incomplete };
|
|
233
|
+
}
|
|
234
|
+
/** Does a scrollback prove exactly `checkout`: at least one complete frame equals it, no complete
|
|
235
|
+
* frame names anything else, and no frame is incomplete. Full-path equality after canonicalization —
|
|
236
|
+
* never a prefix, a substring, or a whitespace-terminated fragment. */
|
|
237
|
+
export function provesCheckout(text, checkout) {
|
|
238
|
+
const { complete, incomplete } = checkoutFrames(text);
|
|
239
|
+
return !incomplete && complete.includes(checkout) && complete.every((c) => c === checkout);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Everything a terminal on the tracked worktree runs before the payload: enter the checkout (a
|
|
243
|
+
* failed cd stops the whole line — nothing of the payload ever runs in the enclosing path), print
|
|
244
|
+
* the proof line, then hand the WHOLE payload to one `sh -c` so a background list, a `;` list or a
|
|
245
|
+
* subshell inside it all start in the checkout and its exit status is the payload's (FX-N02).
|
|
246
|
+
*/
|
|
247
|
+
export function checkoutPrefix(checkout) {
|
|
248
|
+
return `cd ${shq(checkout)} && printf '%s\\n' ${shq(checkoutProofLine(checkout))} && sh -c `;
|
|
249
|
+
}
|
|
250
|
+
/** The command a terminal on the tracked worktree runs so that it executes INSIDE the checkout. */
|
|
251
|
+
export function inCheckout(checkout, cmd) {
|
|
252
|
+
return `${checkoutPrefix(checkout)}${shq(cmd)}`;
|
|
253
|
+
}
|
|
254
|
+
/** Every checkout the complete proof frames in a scrollback name, in order of appearance. */
|
|
255
|
+
export function checkoutsNamed(text) {
|
|
256
|
+
return checkoutFrames(text).complete;
|
|
257
|
+
}
|
|
192
258
|
// Orca has ONE terminal space — no workspace dimension for a terminal to be outside of — so every
|
|
193
259
|
// reconcile candidate takes panesToClose's in-workspace branch: owned-and-undesired closes whichever
|
|
194
260
|
// run (or which daemon) created it, and an unparseable title is never a candidate anywhere.
|
|
@@ -204,12 +270,152 @@ export function mapAgentState(term, tuiIdle) {
|
|
|
204
270
|
return "idle";
|
|
205
271
|
return "unknown"; // absent fields: unknown, never blocked/idle
|
|
206
272
|
}
|
|
273
|
+
function boardState(r) {
|
|
274
|
+
if (r.retired === true)
|
|
275
|
+
return "retired";
|
|
276
|
+
if (typeof r.pid !== "number" || typeof r.armId !== "string")
|
|
277
|
+
return "reserved";
|
|
278
|
+
return r.pane === "" ? "claimed" : "bound";
|
|
279
|
+
}
|
|
280
|
+
/** The record's exact bytes and their parse; undefined only when no record exists. A torn or foreign
|
|
281
|
+
* file throws — it is indeterminate, never absent. */
|
|
282
|
+
function readBoard(path, repo, runId) {
|
|
283
|
+
let raw;
|
|
284
|
+
try {
|
|
285
|
+
raw = readFileSync(path, "utf8");
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error.code === "ENOENT")
|
|
289
|
+
return undefined;
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
const record = JSON.parse(raw);
|
|
293
|
+
if (record.repo !== realpathSync(repo) || record.runId !== runId || typeof record.token !== "string" || typeof record.pane !== "string") {
|
|
294
|
+
throw new Error(`watch owner record ${path} does not describe run ${runId} in ${repo}`);
|
|
295
|
+
}
|
|
296
|
+
return { raw, record };
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Whole-record compare-and-swap. The canonical path stays readable until commit: a mkdir lock
|
|
300
|
+
* excludes other writers, then create-only `link`s the new inode (fails if anything exists) or
|
|
301
|
+
* `rename`s the new file over the live path (POSIX atomic replace — readers see old or new, never
|
|
302
|
+
* absence). A crash that leaves a `.tmp` or `.lock` does not drop the previous record.
|
|
303
|
+
* ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
|
|
304
|
+
* because the narrator writes nothing between reserve and claim, and no transition here swaps one.
|
|
305
|
+
*/
|
|
306
|
+
export function casBoard(family, path, expected, next) {
|
|
307
|
+
const raw = JSON.stringify(next) + "\n";
|
|
308
|
+
const refused = () => new OrcaError(family, `watch owner record ${path} changed underneath; swap refused and the current record kept`, "");
|
|
309
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
310
|
+
const lock = `${path}.lock`;
|
|
311
|
+
const deadline = Date.now() + 2_000;
|
|
312
|
+
for (;;) {
|
|
313
|
+
try {
|
|
314
|
+
mkdirSync(lock);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (error.code !== "EEXIST")
|
|
319
|
+
throw error;
|
|
320
|
+
if (Date.now() >= deadline)
|
|
321
|
+
throw new OrcaError(family, `watch owner record ${path} lock not acquired; current record kept`, "");
|
|
322
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
let current;
|
|
327
|
+
try {
|
|
328
|
+
current = readFileSync(path, "utf8");
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error.code !== "ENOENT")
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
if (current !== expected)
|
|
335
|
+
throw refused();
|
|
336
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
337
|
+
writeFileSync(tmp, raw);
|
|
338
|
+
try {
|
|
339
|
+
if (expected === undefined) {
|
|
340
|
+
try {
|
|
341
|
+
linkSync(tmp, path);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
throw refused();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
renameSync(tmp, path);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
rmSync(tmp, { force: true });
|
|
353
|
+
}
|
|
354
|
+
return raw;
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
rmSync(lock, { recursive: true, force: true });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function pidLive(pid) {
|
|
361
|
+
try {
|
|
362
|
+
process.kill(pid, 0);
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
return error.code === "EPERM";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function terminalRows(env) {
|
|
370
|
+
const rows = env.result.terminals;
|
|
371
|
+
if (!Array.isArray(rows))
|
|
372
|
+
throw new OrcaError("list", "list response carries no terminals array", env.raw);
|
|
373
|
+
return rows.filter((r) => typeof r === "object" && r !== null);
|
|
374
|
+
}
|
|
375
|
+
function listsHandle(env, handle) {
|
|
376
|
+
return terminalRows(env).some((r) => str(r.handle) === handle);
|
|
377
|
+
}
|
|
378
|
+
/** A handle listed by a different runtime is not the recorded pane — Orca handles are runtime-scoped. */
|
|
379
|
+
function listedOnRuntime(env, handle, runtimeId) {
|
|
380
|
+
return typeof runtimeId === "string" && env.runtimeId === runtimeId && listsHandle(env, handle);
|
|
381
|
+
}
|
|
382
|
+
function terminalTabId(env, handle) {
|
|
383
|
+
for (const row of terminalRows(env)) {
|
|
384
|
+
if (str(row.handle) === handle)
|
|
385
|
+
return str(row.tabId);
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Orca 1.4.200 split contract: child at `result.split.handle`, parent tab at `result.split.tabId`.
|
|
391
|
+
* A receipt that only happens to contain a handle is malformed — it does not establish that the
|
|
392
|
+
* child belongs to the launching terminal's tab.
|
|
393
|
+
*/
|
|
394
|
+
function splitReceipt(env) {
|
|
395
|
+
const receipt = env.result.split;
|
|
396
|
+
if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
|
|
397
|
+
throw new OrcaError("split", "split receipt is unknown, malformed or handle-less", env.raw);
|
|
398
|
+
}
|
|
399
|
+
const rec = receipt;
|
|
400
|
+
const handle = str(rec.handle);
|
|
401
|
+
const tabId = str(rec.tabId);
|
|
402
|
+
if (!handle || !tabId) {
|
|
403
|
+
throw new OrcaError("split", "split receipt is unknown, malformed or handle-less", env.raw);
|
|
404
|
+
}
|
|
405
|
+
return { handle, tabId };
|
|
406
|
+
}
|
|
407
|
+
function watchSlot(cwd, name, handle) {
|
|
408
|
+
return { id: handle, name, cwd: canonicalWorktreePath(cwd) };
|
|
409
|
+
}
|
|
410
|
+
function errorText(error) {
|
|
411
|
+
return error instanceof Error ? error.message : String(error);
|
|
412
|
+
}
|
|
207
413
|
/** Terminal-pane handles under a layout tab node: a pane object, an array of them, or a nested
|
|
208
414
|
* group/split carrying `panes`, `first`, `second`. Only `type:"terminal"` leaves count — anything else is chrome. */
|
|
209
|
-
function
|
|
415
|
+
function collectLeaves(node, out) {
|
|
210
416
|
if (Array.isArray(node)) {
|
|
211
417
|
for (const n of node)
|
|
212
|
-
|
|
418
|
+
collectLeaves(n, out);
|
|
213
419
|
return;
|
|
214
420
|
}
|
|
215
421
|
if (typeof node !== "object" || node === null)
|
|
@@ -218,12 +424,18 @@ function collectPaneHandles(node, out) {
|
|
|
218
424
|
if (o.type === "terminal") {
|
|
219
425
|
const h = str(o.handle);
|
|
220
426
|
if (h)
|
|
221
|
-
out.push(h);
|
|
427
|
+
out.push({ handle: h, title: str(o.title) });
|
|
222
428
|
return;
|
|
223
429
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
430
|
+
collectLeaves(o.panes, out);
|
|
431
|
+
collectLeaves(o.first, out);
|
|
432
|
+
collectLeaves(o.second, out);
|
|
433
|
+
}
|
|
434
|
+
function collectPaneHandles(node, out) {
|
|
435
|
+
const leaves = [];
|
|
436
|
+
collectLeaves(node, leaves);
|
|
437
|
+
for (const l of leaves)
|
|
438
|
+
out.push(l.handle);
|
|
227
439
|
}
|
|
228
440
|
function collectTabs(node, out) {
|
|
229
441
|
if (Array.isArray(node)) {
|
|
@@ -266,8 +478,19 @@ export class OrcaDriver {
|
|
|
266
478
|
narrate;
|
|
267
479
|
hookCoverage;
|
|
268
480
|
taskWorktrees = new Map();
|
|
481
|
+
trackedByCheckout = new Map(); // OBS-1004: checkout → the tracked worktree enclosing it
|
|
269
482
|
pendingProjects = new Map();
|
|
483
|
+
env;
|
|
484
|
+
launchingHandle;
|
|
485
|
+
serialQueue = Promise.resolve();
|
|
486
|
+
serial(fn) {
|
|
487
|
+
const next = this.serialQueue.then(fn, fn);
|
|
488
|
+
this.serialQueue = next.then(() => { }, () => { });
|
|
489
|
+
return next;
|
|
490
|
+
}
|
|
270
491
|
constructor(opts = {}) {
|
|
492
|
+
this.env = opts.env ?? process.env;
|
|
493
|
+
this.launchingHandle = opts.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE;
|
|
271
494
|
this.bin = opts.bin ?? resolveOrcaCliBinary(process.cwd(), { env: opts.env, platform: opts.platform }) ?? "orca";
|
|
272
495
|
// Config values flow into a shell here: every argv element is quoted, always.
|
|
273
496
|
this.exec = opts.exec ?? ((args, cwd, timeoutMs) => {
|
|
@@ -437,20 +660,23 @@ export class OrcaDriver {
|
|
|
437
660
|
}
|
|
438
661
|
async create(st, cmd) {
|
|
439
662
|
await this.probeRuntime(this.cliCwd(st));
|
|
440
|
-
await this.
|
|
441
|
-
|
|
442
|
-
//
|
|
663
|
+
const tracked = await this.trackedWorktree(st.cwd);
|
|
664
|
+
st.tracked = tracked;
|
|
665
|
+
// The selector names the TRACKED worktree enclosing this slot's checkout outright — never the
|
|
666
|
+
// UI's active worktree (`active`/`current`) nor the daemon's cwd — and the command itself moves
|
|
667
|
+
// into the checkout, because Orca cannot be asked to place a terminal in a path it does not
|
|
668
|
+
// track (OBS-1004). `--command` runs inside Orca's wrapper shell, so a `cd` prefix is honoured.
|
|
443
669
|
const env = await this.call("create", [
|
|
444
|
-
"terminal", "create", "--worktree", `path:${
|
|
670
|
+
"terminal", "create", "--worktree", `path:${tracked}`, "--title", st.title, "--command", inCheckout(st.cwd, cmd),
|
|
445
671
|
], this.cliCwd(st));
|
|
446
672
|
const term = requireTerminal("create", env);
|
|
447
673
|
const handle = str(term.handle);
|
|
448
674
|
if (!handle)
|
|
449
675
|
throw new OrcaError("create", "create receipt carries no terminal handle", env.raw);
|
|
450
|
-
// Asking is not getting: the receipt says which
|
|
451
|
-
// terminal
|
|
676
|
+
// Asking is not getting: the receipt says which worktree the runtime actually resolved, and a
|
|
677
|
+
// terminal bound elsewhere has already lost the isolation this run is built on.
|
|
452
678
|
const worktree = terminalWorktree(term);
|
|
453
|
-
if (!sameWorktree(worktree,
|
|
679
|
+
if (!sameWorktree(worktree, tracked)) {
|
|
454
680
|
// `terminal create` has ALREADY launched the command in that wrong checkout, so refusing the
|
|
455
681
|
// receipt is not yet fail-closed: the agent keeps mutating it. Close the exact handle this
|
|
456
682
|
// receipt named, under the runtime that answered it (closeTerminal re-proves that identity
|
|
@@ -461,7 +687,7 @@ export class OrcaDriver {
|
|
|
461
687
|
await this.closeTerminal({ ...st, handle, runtimeId: env.runtimeId });
|
|
462
688
|
}
|
|
463
689
|
catch { /* already gone, or no longer provably ours — never a blind retry */ }
|
|
464
|
-
throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the
|
|
690
|
+
throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the tracked ${tracked} enclosing ${st.cwd}`, env.raw);
|
|
465
691
|
}
|
|
466
692
|
st.handle = handle;
|
|
467
693
|
// The handle is bound to the runtime identity that ANSWERED its create.
|
|
@@ -473,62 +699,30 @@ export class OrcaDriver {
|
|
|
473
699
|
}
|
|
474
700
|
}
|
|
475
701
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
702
|
+
* OBS-1004: the tracked worktree that encloses a checkout, asked ONCE of `worktree current` from
|
|
703
|
+
* inside that checkout. Orca answers the exact path when it tracks the checkout itself, the
|
|
704
|
+
* enclosing tracked clone for a git worktree the daemon added beneath it (1.4.200, verified from
|
|
705
|
+
* `.tickmarkr/worktrees.noindex/<task>`), and selector_not_found when nothing it tracks encloses
|
|
706
|
+
* the cwd — which is a driver failure, not something to wait out: Orca has no adopt verb.
|
|
480
707
|
*/
|
|
481
|
-
async
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
try {
|
|
490
|
-
const env = await this.call("worktree-current", ["worktree", "current"], st.cwd, Math.max(1, left));
|
|
491
|
-
const worktree = env.result.worktree;
|
|
492
|
-
if (typeof worktree !== "object" || worktree === null || Array.isArray(worktree)) {
|
|
493
|
-
throw new OrcaError("worktree-current", "response carries no worktree record", env.raw, { runtimeId: env.runtimeId });
|
|
494
|
-
}
|
|
495
|
-
const reported = terminalWorktree(worktree);
|
|
496
|
-
if (!reported) {
|
|
497
|
-
throw new OrcaError("worktree-current", "worktree record carries no path", env.raw, { runtimeId: env.runtimeId });
|
|
498
|
-
}
|
|
499
|
-
lastReported = canonicalWorktreePath(reported);
|
|
500
|
-
if (lastReported === st.cwd) {
|
|
501
|
-
const waitedMs = this.time.now() - started;
|
|
502
|
-
if (waitedMs > WORKTREE_ADOPTION_JOURNAL_MS)
|
|
503
|
-
this.appendAdoptionWait(st, waitedMs);
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
catch (error) {
|
|
508
|
-
if (!(error instanceof OrcaError) || error.code !== "selector_not_found")
|
|
509
|
-
throw error;
|
|
510
|
-
lastReported = undefined;
|
|
511
|
-
}
|
|
512
|
-
const remaining = deadline - this.time.now();
|
|
513
|
-
if (remaining <= 0)
|
|
514
|
-
break;
|
|
515
|
-
await this.time.sleep(Math.min(WORKTREE_ADOPTION_POLL_MS, remaining));
|
|
708
|
+
async trackedWorktree(checkout) {
|
|
709
|
+
const cached = this.trackedByCheckout.get(checkout);
|
|
710
|
+
if (cached)
|
|
711
|
+
return cached;
|
|
712
|
+
const env = await this.call("worktree-current", ["worktree", "current"], checkout);
|
|
713
|
+
const worktree = env.result.worktree;
|
|
714
|
+
if (typeof worktree !== "object" || worktree === null || Array.isArray(worktree)) {
|
|
715
|
+
throw new OrcaError("worktree-current", "response carries no worktree record", env.raw, { runtimeId: env.runtimeId });
|
|
516
716
|
}
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (!owned)
|
|
524
|
-
throw new Error(`cannot journal worktree-adoption-wait: slot ${st.title} carries no run identity`);
|
|
525
|
-
const repoRoot = this.journalRoots.get(st.cwd);
|
|
526
|
-
if (!repoRoot) {
|
|
527
|
-
throw new Error(`cannot journal worktree-adoption-wait: slot ${st.title} has no daemon repo binding for ${st.cwd}`);
|
|
717
|
+
const reported = terminalWorktree(worktree);
|
|
718
|
+
if (!reported)
|
|
719
|
+
throw new OrcaError("worktree-current", "worktree record carries no path", env.raw, { runtimeId: env.runtimeId });
|
|
720
|
+
const tracked = canonicalWorktreePath(reported);
|
|
721
|
+
if (tracked !== checkout && !checkout.startsWith(`${tracked}/`)) {
|
|
722
|
+
throw new OrcaError("worktree-current", `Orca answered ${tracked}, which does not enclose ${checkout}`, env.raw, { runtimeId: env.runtimeId });
|
|
528
723
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
});
|
|
724
|
+
this.trackedByCheckout.set(checkout, tracked);
|
|
725
|
+
return tracked;
|
|
532
726
|
}
|
|
533
727
|
// ---- handle identity and restart recovery ----------------------------------------------------
|
|
534
728
|
/**
|
|
@@ -605,6 +799,65 @@ export class OrcaDriver {
|
|
|
605
799
|
return env;
|
|
606
800
|
}
|
|
607
801
|
}
|
|
802
|
+
/**
|
|
803
|
+
* FX-N01/N05/N06: under a shared enclosing worktree every task terminal lists with the same
|
|
804
|
+
* worktreePath, so the tracked path + owned title cannot tell two nested checkouts apart. The
|
|
805
|
+
* runtime's own proof is the terminal's earliest scrollback, where the create command printed a
|
|
806
|
+
* framed `TICKMARKR_CHECKOUT` line before its payload (checkoutProofLine). READ-only calls: the
|
|
807
|
+
* anchor (for `oldestCursor`), then pages from the oldest cursor until a frame is complete or the
|
|
808
|
+
* bound is hit. Every page is evidence only when the response's own identity is the candidate's:
|
|
809
|
+
* the terminal record must name `handle` and `_meta.runtimeId` must be `runtimeId` — the runtime
|
|
810
|
+
* that supplied the ownership listing — else another terminal's or another runtime's bytes were
|
|
811
|
+
* answered and nothing is proven. Proven means provesCheckout: exact canonical full-path equality
|
|
812
|
+
* of a complete frame, no other checkout named, no incomplete frame.
|
|
813
|
+
*/
|
|
814
|
+
async checkoutProven(handle, checkout, from, runtimeId) {
|
|
815
|
+
const page = async (cursor) => {
|
|
816
|
+
const env = await this.call("read", [
|
|
817
|
+
"terminal", "read", "--terminal", handle, ...(cursor === undefined ? [] : ["--cursor", cursor]), "--limit", String(this.pageLines),
|
|
818
|
+
], from);
|
|
819
|
+
if (env.runtimeId !== runtimeId) {
|
|
820
|
+
throw new OrcaError("read", `proof page answered by runtime ${env.runtimeId}, not the listing's ${runtimeId}`, env.raw);
|
|
821
|
+
}
|
|
822
|
+
const term = requireTerminal("read", env);
|
|
823
|
+
if (str(term.handle) !== handle) {
|
|
824
|
+
throw new OrcaError("read", `proof page names terminal ${str(term.handle) ?? "none"}, not the candidate ${handle}`, env.raw);
|
|
825
|
+
}
|
|
826
|
+
return term;
|
|
827
|
+
};
|
|
828
|
+
const lines = (term) => Array.isArray(term.tail) ? term.tail.filter((l) => typeof l === "string") : [];
|
|
829
|
+
try {
|
|
830
|
+
const anchor = await page();
|
|
831
|
+
let cursor = str(anchor.oldestCursor);
|
|
832
|
+
let text = cursor === undefined ? lines(anchor).join("\n") : "";
|
|
833
|
+
for (let n = 0; cursor !== undefined && n < PROOF_PAGES; n++) {
|
|
834
|
+
const term = await page(cursor);
|
|
835
|
+
text += `${lines(term).join("\n")}\n`;
|
|
836
|
+
const frames = checkoutFrames(text);
|
|
837
|
+
if (frames.complete.length > 0 && !frames.incomplete)
|
|
838
|
+
break; // whole frames, nothing dangling
|
|
839
|
+
const next = str(term.nextCursor);
|
|
840
|
+
if (term.limited !== true || next === undefined || next === cursor)
|
|
841
|
+
break;
|
|
842
|
+
cursor = next;
|
|
843
|
+
}
|
|
844
|
+
const frames = checkoutFrames(text);
|
|
845
|
+
// Reconcile has no task-checkout path after a daemon restart, but the proof itself remains
|
|
846
|
+
// an ownership record: exactly one complete, unambiguous checkout frame can only have been
|
|
847
|
+
// written by tickmarkr's create command. Slot recovery additionally requires its exact path.
|
|
848
|
+
if (checkout === undefined
|
|
849
|
+
? !frames.incomplete && frames.complete.length > 0 && new Set(frames.complete).size === 1
|
|
850
|
+
: provesCheckout(text, checkout))
|
|
851
|
+
return { proven: true, reason: "" };
|
|
852
|
+
return {
|
|
853
|
+
proven: false,
|
|
854
|
+
reason: `its scrollback ${frames.complete.length ? `names ${[...new Set(frames.complete)].join(", ")}` : "names no checkout"}${frames.incomplete ? " and carries an incomplete proof frame" : ""}`,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
return { proven: false, reason: error instanceof Error ? error.message : String(error) };
|
|
859
|
+
}
|
|
860
|
+
}
|
|
608
861
|
async recover(family, st, newRuntimeId, raw) {
|
|
609
862
|
if (st.recovering)
|
|
610
863
|
throw this.latched(family, st, "handle recovery re-entered", raw);
|
|
@@ -613,7 +866,8 @@ export class OrcaDriver {
|
|
|
613
866
|
const old = st.handle;
|
|
614
867
|
// visualLayouts is required: the owned title survives at TAB identity only, and rows carry
|
|
615
868
|
// just the shell-controlled pane title (recorded: "…probe…" at create → "bash" on the row).
|
|
616
|
-
const
|
|
869
|
+
const home = st.tracked ?? st.cwd; // the worktree Orca placed this terminal in (OBS-1004)
|
|
870
|
+
const env = await this.call("list", ["terminal", "list", "--worktree", `path:${home}`, "--include-visual-layouts", "--limit", String(LIST_LIMIT)], this.cliCwd(st));
|
|
617
871
|
const listed = env.result.terminals;
|
|
618
872
|
if (!Array.isArray(listed))
|
|
619
873
|
throw new OrcaError("list", "list response carries no terminals array", env.raw);
|
|
@@ -625,7 +879,7 @@ export class OrcaDriver {
|
|
|
625
879
|
// The worktree-authoritative rows of THIS worktree: a handle is adoptable only if a row in
|
|
626
880
|
// the slot's exact worktree backs it. A same-titled tab in another worktree is a lookalike.
|
|
627
881
|
const inWorktree = new Set(listed
|
|
628
|
-
.filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t),
|
|
882
|
+
.filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t), home))
|
|
629
883
|
.map((t) => str(t.handle))
|
|
630
884
|
.filter((h) => h !== undefined));
|
|
631
885
|
// Exactly one tab carrying the FULL owned title, resolving to exactly one terminal pane.
|
|
@@ -636,7 +890,7 @@ export class OrcaDriver {
|
|
|
636
890
|
if (typeof layout !== "object" || layout === null)
|
|
637
891
|
continue;
|
|
638
892
|
const lo = layout;
|
|
639
|
-
if (!sameWorktree(terminalWorktree(lo),
|
|
893
|
+
if (!sameWorktree(terminalWorktree(lo), home))
|
|
640
894
|
continue; // another worktree's tabs are never candidates
|
|
641
895
|
const tabs = [];
|
|
642
896
|
collectTabs(lo.root, tabs);
|
|
@@ -649,14 +903,14 @@ export class OrcaDriver {
|
|
|
649
903
|
}
|
|
650
904
|
}
|
|
651
905
|
if (ownedTabs.length === 0) {
|
|
652
|
-
throw this.latched(family, st, `no tab in ${
|
|
906
|
+
throw this.latched(family, st, `no tab in ${home} carries the owned title ${st.title} (row titles are shell-controlled and are never ownership keys)`, env.raw);
|
|
653
907
|
}
|
|
654
908
|
if (ownedTabs.length > 1) {
|
|
655
|
-
throw this.latched(family, st, `${ownedTabs.length} tabs in ${
|
|
909
|
+
throw this.latched(family, st, `${ownedTabs.length} tabs in ${home} carry the owned title ${st.title} — ambiguous`, env.raw);
|
|
656
910
|
}
|
|
657
911
|
const panes = ownedTabs[0].filter((h) => inWorktree.has(h));
|
|
658
912
|
if (panes.length !== 1) {
|
|
659
|
-
throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${
|
|
913
|
+
throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${home}`, env.raw);
|
|
660
914
|
}
|
|
661
915
|
const handle = panes[0];
|
|
662
916
|
if (handle === old) {
|
|
@@ -664,6 +918,14 @@ export class OrcaDriver {
|
|
|
664
918
|
// which terminal it addresses, so it is never adopted.
|
|
665
919
|
throw this.latched(family, st, `replacement handle ${handle} is the old handle value reused by runtime ${newRuntimeId ?? env.runtimeId ?? "unknown"}`, env.raw);
|
|
666
920
|
}
|
|
921
|
+
if (st.tracked !== undefined && st.tracked !== st.cwd) {
|
|
922
|
+
// FX-N01: a nested checkout shares its worktreePath with every sibling task's terminal, so
|
|
923
|
+
// the candidate must prove the checkout itself (read-only) before it is addressed as ours.
|
|
924
|
+
const proof = await this.checkoutProven(handle, st.cwd, this.cliCwd(st), env.runtimeId);
|
|
925
|
+
if (!proof.proven) {
|
|
926
|
+
throw this.latched(family, st, `candidate ${handle} does not prove checkout ${st.cwd} (${proof.reason}); the sole same-titled tab on ${home} is not adopted`, env.raw);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
667
929
|
st.handle = handle;
|
|
668
930
|
// The list response is the identity proof for the replacement. A stale refusal may have been
|
|
669
931
|
// produced by either side of a restart; its metadata never overrides the runtime that relisted.
|
|
@@ -997,12 +1259,181 @@ export class OrcaDriver {
|
|
|
997
1259
|
return false;
|
|
998
1260
|
}
|
|
999
1261
|
}
|
|
1000
|
-
async narrator(
|
|
1262
|
+
async narrator(cwd, command, runId) {
|
|
1001
1263
|
if (!runId)
|
|
1002
|
-
throw new OrcaError("
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1264
|
+
throw new OrcaError("split", "Orca narrator requires a run identity", "");
|
|
1265
|
+
const launchingHandle = (this.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE)?.trim();
|
|
1266
|
+
if (!launchingHandle)
|
|
1267
|
+
throw new OrcaError("split", "Orca narrator requires ORCA_TERMINAL_HANDLE", "");
|
|
1268
|
+
return this.serial(async () => {
|
|
1269
|
+
const name = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
|
|
1270
|
+
const path = this.boardPath(cwd, runId);
|
|
1271
|
+
const kept = `indeterminate cleanup refused — owner record ${path} kept`;
|
|
1272
|
+
let current = readBoard(path, cwd, runId);
|
|
1273
|
+
if (current && (current.record.driver !== this.id || current.record.name !== name)) {
|
|
1274
|
+
// Never overwrite a record this driver did not create, unless its observer is provably gone.
|
|
1275
|
+
const { driver, pid } = current.record;
|
|
1276
|
+
if (typeof pid !== "number" || pidLive(pid)) {
|
|
1277
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: the record is held by driver ${driver} with a live or unclaimed observer; ${kept}`, "");
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
else if (current) {
|
|
1281
|
+
const state = boardState(current.record);
|
|
1282
|
+
if (state === "reserved" || state === "claimed") {
|
|
1283
|
+
throw new OrcaError("split", `Orca narrator placement remains unresolved for ${name} (${state}, no bound pane); ${kept}`, "");
|
|
1284
|
+
}
|
|
1285
|
+
if (state === "bound") {
|
|
1286
|
+
// The bound record is the durable truth of placement: the child handle PLUS the split
|
|
1287
|
+
// envelope's runtime identity. A handle listed by a later runtime is a different pane.
|
|
1288
|
+
if (listedOnRuntime(await this.listAll(cwd), current.record.pane, current.record.runtimeId)) {
|
|
1289
|
+
return watchSlot(cwd, name, current.record.pane);
|
|
1290
|
+
}
|
|
1291
|
+
// Lost: this runtime no longer has that pane. Tombstone before anything else.
|
|
1292
|
+
const retired = { ...current.record, retired: true };
|
|
1293
|
+
current = { raw: casBoard("split", path, current.raw, retired), record: retired };
|
|
1294
|
+
}
|
|
1295
|
+
// A tombstone can be an acknowledgement timeout, not completed observer cleanup. A live
|
|
1296
|
+
// observer must stop and ack on injected time before the handle-bound close; timeout keeps
|
|
1297
|
+
// the tombstone and does not replace it.
|
|
1298
|
+
try {
|
|
1299
|
+
if (typeof current.record.pid === "number" && pidLive(current.record.pid)) {
|
|
1300
|
+
await stopWatchBoard(current.record, this.time);
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
requestWatchBoardStop(current.record);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: retired board observer unacknowledged; ${kept}; ${errorText(error)}`, "");
|
|
1308
|
+
}
|
|
1309
|
+
try {
|
|
1310
|
+
await this.closeRecordedPane(cwd, name, current.record.pane, current.record.runtimeId);
|
|
1311
|
+
}
|
|
1312
|
+
catch (error) {
|
|
1313
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: retired board pane ${current.record.pane} not proven closed; ${kept}; ${errorText(error)}`, "");
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// Reserve before any command can read the token: create-only, or a swap of the exact record
|
|
1317
|
+
// judged replaceable above.
|
|
1318
|
+
const token = randomUUID();
|
|
1319
|
+
casBoard("split", path, current?.raw, { repo: realpathSync(cwd), runId, driver: this.id, workspace: ORCA_SPACE, pane: "", name, token });
|
|
1320
|
+
// Exactly one terminal split of the launching handle, horizontal, carrying token and command.
|
|
1321
|
+
// An unknown receipt (transport failure, refusal, unparseable output) after the verb was issued
|
|
1322
|
+
// is as indeterminate as a handle-less one: the pane may exist, so the reservation stays.
|
|
1323
|
+
const env = await this.call("split", [
|
|
1324
|
+
"terminal", "split",
|
|
1325
|
+
"--terminal", launchingHandle,
|
|
1326
|
+
"--direction", "horizontal",
|
|
1327
|
+
"--command", `${WATCH_OWNER_ENV}=${shq(token)} ${command}`,
|
|
1328
|
+
], cwd).catch((error) => {
|
|
1329
|
+
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown (${error instanceof OrcaError ? error.reason : errorText(error)}); ${kept}`, error instanceof OrcaError ? error.raw : "");
|
|
1330
|
+
});
|
|
1331
|
+
let child;
|
|
1332
|
+
try {
|
|
1333
|
+
child = splitReceipt(env);
|
|
1334
|
+
}
|
|
1335
|
+
catch {
|
|
1336
|
+
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
|
|
1337
|
+
}
|
|
1338
|
+
// Parent tabId must name the launching terminal's tab, and the child must actually appear
|
|
1339
|
+
// there. A handle-only object, a tabId for some other tab, or a handle the list does not
|
|
1340
|
+
// place in that tab is malformed — closing it would be a guessed handle.
|
|
1341
|
+
let listing;
|
|
1342
|
+
try {
|
|
1343
|
+
listing = await this.listAll(cwd);
|
|
1344
|
+
}
|
|
1345
|
+
catch (error) {
|
|
1346
|
+
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, error instanceof OrcaError ? error.raw : env.raw);
|
|
1347
|
+
}
|
|
1348
|
+
const launchingTabId = terminalTabId(listing, launchingHandle);
|
|
1349
|
+
if (!launchingTabId
|
|
1350
|
+
|| child.tabId !== launchingTabId
|
|
1351
|
+
|| child.handle === launchingHandle
|
|
1352
|
+
|| terminalTabId(listing, child.handle) !== launchingTabId) {
|
|
1353
|
+
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
|
|
1354
|
+
}
|
|
1355
|
+
const childHandle = child.handle;
|
|
1356
|
+
// The narrator writes nothing until the observer's single claim is visible, so neither write
|
|
1357
|
+
// can erase the other; after it the observer never writes the record again.
|
|
1358
|
+
const claimDeadline = this.time.now() + 5000;
|
|
1359
|
+
let claim;
|
|
1360
|
+
for (;;) {
|
|
1361
|
+
const check = readBoard(path, cwd, runId);
|
|
1362
|
+
if (check?.record.token !== token)
|
|
1363
|
+
break;
|
|
1364
|
+
if (boardState(check.record) === "claimed") {
|
|
1365
|
+
claim = check;
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
if (this.time.now() > claimDeadline)
|
|
1369
|
+
break;
|
|
1370
|
+
await this.time.sleep(20);
|
|
1371
|
+
}
|
|
1372
|
+
if (!claim) {
|
|
1373
|
+
// The receipt's handle may be closed, but the refused placement's record stays as it is.
|
|
1374
|
+
let pane;
|
|
1375
|
+
try {
|
|
1376
|
+
await this.closeRecordedPane(cwd, name, childHandle, env.runtimeId);
|
|
1377
|
+
pane = `its pane ${childHandle} was closed`;
|
|
1378
|
+
}
|
|
1379
|
+
catch (error) {
|
|
1380
|
+
pane = `its pane ${childHandle} was not proven closed (${errorText(error)})`;
|
|
1381
|
+
}
|
|
1382
|
+
throw new OrcaError("split", `Orca narrator board unclaimed for ${name}: the observer never claimed the record (unclaimed board); ${kept}; ${pane}`, env.raw);
|
|
1383
|
+
}
|
|
1384
|
+
casBoard("split", path, claim.raw, { ...claim.record, pane: childHandle, runtimeId: env.runtimeId });
|
|
1385
|
+
return watchSlot(cwd, name, childHandle);
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
boardPath(cwd, runId) {
|
|
1389
|
+
return join(cwd, stateDirName(cwd), "supervision", `watch-board.${parseRunId(runId)}.json`);
|
|
1390
|
+
}
|
|
1391
|
+
/** A recorded pane (a receipt's handle bound to the split envelope's runtime, never a guess) is
|
|
1392
|
+
* gone when that runtime no longer lists it, or when a handle-bound close receipt names it.
|
|
1393
|
+
* A handle listed by a different runtime is a different pane — not closed, treated as gone. */
|
|
1394
|
+
async closeRecordedPane(cwd, name, handle, runtimeId) {
|
|
1395
|
+
const listing = await this.listAll(cwd);
|
|
1396
|
+
if (!listedOnRuntime(listing, handle, runtimeId))
|
|
1397
|
+
return;
|
|
1398
|
+
await this.closeTerminal({
|
|
1399
|
+
title: name, cwd: canonicalWorktreePath(cwd), handle, runtimeId, buf: "", recoveries: 0, recovering: false,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
/** bound → retired, decided from the record alone: it must be this driver's board for exactly this
|
|
1403
|
+
* slot's pane. Already retired is returned as it is. */
|
|
1404
|
+
retireBoard(family, slot) {
|
|
1405
|
+
const runId = parseOwnedName(slot.name)?.runId;
|
|
1406
|
+
const path = runId ? this.boardPath(slot.cwd, runId) : undefined;
|
|
1407
|
+
const current = path && runId ? readBoard(path, slot.cwd, runId) : undefined;
|
|
1408
|
+
const r = current?.record;
|
|
1409
|
+
if (!path || !current || !r || r.driver !== this.id || r.name !== slot.name || r.pane !== slot.id || boardState(r) === "reserved" || boardState(r) === "claimed") {
|
|
1410
|
+
throw new OrcaError(family, `watch ownership unknown or foreign for ${slot.name}; existing board protected`, "");
|
|
1411
|
+
}
|
|
1412
|
+
if (r.retired)
|
|
1413
|
+
return r;
|
|
1414
|
+
const retired = { ...r, retired: true };
|
|
1415
|
+
casBoard(family, path, current.raw, retired);
|
|
1416
|
+
return retired;
|
|
1417
|
+
}
|
|
1418
|
+
/** bound → retired first: whatever fails below, no later call answers this board again. A live
|
|
1419
|
+
* observer is asked to stop and its acknowledgement awaited on injected time before the
|
|
1420
|
+
* handle-bound close (timeout keeps the tombstone and the pane); a dead one never acknowledges,
|
|
1421
|
+
* so it is only asked. */
|
|
1422
|
+
async retireAndClose(slot) {
|
|
1423
|
+
await this.serial(async () => {
|
|
1424
|
+
const retired = this.retireBoard("close", slot);
|
|
1425
|
+
if (typeof retired.pid === "number" && pidLive(retired.pid))
|
|
1426
|
+
await stopWatchBoard(retired, this.time);
|
|
1427
|
+
else
|
|
1428
|
+
requestWatchBoardStop(retired);
|
|
1429
|
+
await this.closeRecordedPane(slot.cwd, slot.name, slot.id, retired.runtimeId);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
/** WB-1 seam: the daemon reports this board lost. "Lost" can be a stale beat or missing presence
|
|
1433
|
+
* under a still-live owner pid, so it is not proof of a dead observer — retirement keeps close's
|
|
1434
|
+
* acknowledgement discipline (Leg-2 T9 P1). */
|
|
1435
|
+
async retireLostWatch(slot) {
|
|
1436
|
+
await this.retireAndClose(slot);
|
|
1006
1437
|
}
|
|
1007
1438
|
async focus(target) {
|
|
1008
1439
|
const { slot, runId, taskId, attempt } = target;
|
|
@@ -1021,8 +1452,8 @@ export class OrcaDriver {
|
|
|
1021
1452
|
if (typeof layout !== "object" || layout === null)
|
|
1022
1453
|
continue;
|
|
1023
1454
|
const lo = layout;
|
|
1024
|
-
if (!
|
|
1025
|
-
continue;
|
|
1455
|
+
if (!enclosesCheckout(terminalWorktree(lo), cwd))
|
|
1456
|
+
continue; // the tracked worktree Orca placed it in (OBS-1004)
|
|
1026
1457
|
const tabs = [];
|
|
1027
1458
|
collectTabs(lo.root, tabs);
|
|
1028
1459
|
for (const tab of tabs) {
|
|
@@ -1032,9 +1463,15 @@ export class OrcaDriver {
|
|
|
1032
1463
|
}
|
|
1033
1464
|
if (handles.length !== 1)
|
|
1034
1465
|
return { status: rows.length ? "foreign" : "closed", reason: "No unique owned terminal in the recorded worktree" };
|
|
1035
|
-
const matches = rows.filter(row => typeof row === "object" && row !== null && str(row.handle) === handles[0] &&
|
|
1466
|
+
const matches = rows.filter(row => typeof row === "object" && row !== null && str(row.handle) === handles[0] && enclosesCheckout(terminalWorktree(row), cwd));
|
|
1036
1467
|
if (matches.length !== 1)
|
|
1037
1468
|
return { status: "foreign", reason: "Terminal worktree ownership is unverified" };
|
|
1469
|
+
if (!sameWorktree(terminalWorktree(matches[0]), cwd)) {
|
|
1470
|
+
// FX-N01: the row only ENCLOSES the recorded checkout — require the terminal's own proof line.
|
|
1471
|
+
const proof = await this.checkoutProven(handles[0], cwd, cwd, env.runtimeId);
|
|
1472
|
+
if (!proof.proven)
|
|
1473
|
+
return { status: "foreign", reason: `Terminal checkout ownership is unverified: ${proof.reason}` };
|
|
1474
|
+
}
|
|
1038
1475
|
if (matches[0].connected === false || matches[0].orphaned === true)
|
|
1039
1476
|
return { status: "closed", reason: "Recorded terminal is no longer running; open task evidence" };
|
|
1040
1477
|
return { status: "unsupported", reason: "Owned terminal verified; this Orca API has no focus operation. Open task evidence with Enter" };
|
|
@@ -1053,12 +1490,15 @@ export class OrcaDriver {
|
|
|
1053
1490
|
}
|
|
1054
1491
|
await this.setWorkspaceStatus(worktree, state);
|
|
1055
1492
|
}
|
|
1056
|
-
async setWorkspaceStatus(
|
|
1493
|
+
async setWorkspaceStatus(checkout, state) {
|
|
1494
|
+
// OBS-1004: the task checkout is not an Orca selector (`selector_not_found`, recorded on run
|
|
1495
|
+
// 0004's T5); the projection lands on the tracked worktree that encloses it.
|
|
1496
|
+
const tracked = await this.trackedWorktree(checkout);
|
|
1057
1497
|
// UNRECORDED SHAPE: no Orca 1.4.195 `worktree set` receipt was captured. The shared envelope
|
|
1058
1498
|
// parser is the complete success proof here; no result payload is assumed or fabricated.
|
|
1059
1499
|
await this.call("worktree-set", [
|
|
1060
|
-
"worktree", "set", "--worktree", `path:${
|
|
1061
|
-
],
|
|
1500
|
+
"worktree", "set", "--worktree", `path:${tracked}`, "--workspace-status", state,
|
|
1501
|
+
], checkout);
|
|
1062
1502
|
}
|
|
1063
1503
|
async notify(msg, opts) {
|
|
1064
1504
|
if (opts?.tier === "routine")
|
|
@@ -1069,11 +1509,16 @@ export class OrcaDriver {
|
|
|
1069
1509
|
this.narrate = narrate;
|
|
1070
1510
|
}
|
|
1071
1511
|
async close(slot) {
|
|
1512
|
+
if (parseOwnedName(slot.name)?.role === "watch") {
|
|
1513
|
+
await this.retireAndClose(slot);
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1072
1516
|
const st = this.slots.get(slot.id);
|
|
1073
1517
|
if (!st)
|
|
1074
1518
|
return;
|
|
1075
|
-
if (st.handle)
|
|
1519
|
+
if (st.handle) {
|
|
1076
1520
|
await this.closeTerminal(st);
|
|
1521
|
+
}
|
|
1077
1522
|
this.slots.delete(slot.id);
|
|
1078
1523
|
}
|
|
1079
1524
|
/**
|
|
@@ -1204,10 +1649,24 @@ export class OrcaDriver {
|
|
|
1204
1649
|
const title = str(t.title);
|
|
1205
1650
|
if (!title)
|
|
1206
1651
|
continue;
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1652
|
+
const leaves = [];
|
|
1653
|
+
collectLeaves(t.panes, leaves);
|
|
1654
|
+
const launching = (this.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE)?.trim();
|
|
1655
|
+
// A tab title belongs to the tab, not to a leaf. After the operator moves the worker
|
|
1656
|
+
// out, a foreign shell can sit alone under that owned title — so every leaf, including
|
|
1657
|
+
// the only leaf of a single-leaf tab, needs the durable checkout proof. A title that
|
|
1658
|
+
// does not parse is never a candidate.
|
|
1659
|
+
if (!parseOwnedName(title))
|
|
1660
|
+
continue;
|
|
1661
|
+
for (const leaf of leaves) {
|
|
1662
|
+
if (launching && leaf.handle === launching)
|
|
1663
|
+
continue;
|
|
1664
|
+
if (this.isRecordedWatchHandle(leaf.handle, worktree, runId, env.runtimeId))
|
|
1665
|
+
continue;
|
|
1666
|
+
if (!await this.isRecordedWorkerHandle(leaf.handle, from, env.runtimeId))
|
|
1667
|
+
continue;
|
|
1668
|
+
candidates.set(leaf.handle, { title, worktree: canonicalWorktreePath(worktree) });
|
|
1669
|
+
}
|
|
1211
1670
|
}
|
|
1212
1671
|
}
|
|
1213
1672
|
const toClose = panesToClose([...candidates].map(([paneId, c]) => ({ name: c.title, paneId, workspaceId: ORCA_SPACE })), desired, ORCA_SPACE, runId, opts);
|
|
@@ -1232,6 +1691,36 @@ export class OrcaDriver {
|
|
|
1232
1691
|
}
|
|
1233
1692
|
catch { /* cosmetic — visibility hygiene never fails the run */ }
|
|
1234
1693
|
}
|
|
1694
|
+
isRecordedWatchHandle(handle, cwd, runId, runtimeId) {
|
|
1695
|
+
const matches = (record) => !!record && record.pane === handle && record.runtimeId === runtimeId;
|
|
1696
|
+
if (runId && matches(readWatchBoard(cwd, runId)))
|
|
1697
|
+
return true;
|
|
1698
|
+
try {
|
|
1699
|
+
const dir = join(cwd, stateDirName(cwd), "supervision");
|
|
1700
|
+
if (existsSync(dir)) {
|
|
1701
|
+
for (const f of readdirSync(dir)) {
|
|
1702
|
+
if (f.startsWith("watch-board.") && f.endsWith(".json")) {
|
|
1703
|
+
const content = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
1704
|
+
if (content && typeof content === "object" && matches(content))
|
|
1705
|
+
return true;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
catch (error) {
|
|
1711
|
+
// Ownership uncertainty fails closed: abort this best-effort reconcile before it can treat a
|
|
1712
|
+
// recorded watch as an ordinary title-keyed worker. The outer reconcile boundary remains
|
|
1713
|
+
// cosmetic, but no close is attempted from a partial supervision-directory read.
|
|
1714
|
+
throw new OrcaError("list", `watch ownership unreadable in ${cwd}: ${errorText(error)}`, "");
|
|
1715
|
+
}
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
async isRecordedWorkerHandle(handle, from, runtimeId) {
|
|
1719
|
+
// Do not use the driver's in-memory slots as the boundary: reconcile is also responsible for
|
|
1720
|
+
// terminals made before this driver process started. `create()` writes this proof before its
|
|
1721
|
+
// worker payload, and checkoutProven reads it from Orca rather than trusting a fixture handle.
|
|
1722
|
+
return (await this.checkoutProven(handle, undefined, from, runtimeId)).proven;
|
|
1723
|
+
}
|
|
1235
1724
|
// tickmarkr's own createWorktree stays the sole checkout authority — orca never makes worktrees.
|
|
1236
1725
|
async worktree(repo, branch, baseRef) {
|
|
1237
1726
|
const worktree = await createWorktree(repo, branch, baseRef);
|