tickmarkr 2.5.4 → 2.5.6
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 +3 -1
- 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 +118 -9
- package/dist/cli/commands/doctor.d.ts +10 -0
- package/dist/cli/commands/doctor.js +44 -29
- package/dist/cli/commands/fleet.js +199 -33
- package/dist/cli/commands/init.js +196 -6
- package/dist/cli/commands/plan.js +20 -4
- package/dist/cli/commands/resume.js +4 -2
- package/dist/cli/commands/run.js +11 -2
- package/dist/cli/commands/verify.js +102 -81
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +3 -1
- package/dist/config/config.d.ts +25 -8
- package/dist/config/config.js +41 -29
- package/dist/config/fleet-overlay.d.ts +3 -9
- package/dist/config/fleet-overlay.js +114 -19
- package/dist/config/fleet-why.d.ts +7 -0
- package/dist/config/fleet-why.js +5 -0
- package/dist/drivers/index.d.ts +15 -1
- package/dist/drivers/index.js +39 -10
- package/dist/drivers/orca.d.ts +119 -10
- package/dist/drivers/orca.js +781 -110
- package/dist/gates/baseline.d.ts +17 -3
- package/dist/gates/baseline.js +63 -15
- package/dist/gates/cache.d.ts +101 -0
- package/dist/gates/cache.js +401 -0
- package/dist/gates/llm.d.ts +3 -0
- package/dist/gates/llm.js +11 -0
- package/dist/gates/review.d.ts +28 -3
- package/dist/gates/review.js +106 -14
- package/dist/gates/run-gates.d.ts +9 -1
- package/dist/gates/run-gates.js +407 -102
- package/dist/gates/test-manifest.d.ts +128 -0
- package/dist/gates/test-manifest.js +463 -0
- package/dist/gates/test-reporter.d.ts +4 -0
- package/dist/gates/test-reporter.js +57 -0
- package/dist/graph/graph.d.ts +2 -0
- package/dist/graph/graph.js +45 -1
- package/dist/route/preference.d.ts +22 -1
- package/dist/route/preference.js +123 -25
- package/dist/route/router.js +31 -6
- package/dist/run/consult.js +5 -4
- package/dist/run/daemon.d.ts +15 -0
- package/dist/run/daemon.js +638 -167
- package/dist/run/execution-budget.d.ts +25 -0
- package/dist/run/execution-budget.js +142 -0
- package/dist/run/git.d.ts +50 -1
- package/dist/run/git.js +131 -12
- package/dist/run/journal.d.ts +16 -2
- package/dist/run/journal.js +115 -25
- package/dist/run/lease.d.ts +58 -0
- package/dist/run/lease.js +310 -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/run/recovery.d.ts +8 -0
- package/dist/run/recovery.js +25 -0
- package/dist/run/repair-selection.d.ts +12 -0
- package/dist/run/repair-selection.js +56 -0
- package/dist/run/stall.d.ts +6 -1
- package/dist/run/stall.js +60 -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 +71 -21
- 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 +49 -27
- package/dist/tui/ink/fleet-app.js +229 -38
- package/package.json +2 -2
- package/skills/tickmarkr-overseer/SKILL.md +173 -113
- package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
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, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, symlinkSync, unlinkSync, 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,243 @@ 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
|
+
* Each lock generation publishes its pid and nonce atomically in a symlink target. A holder that died
|
|
304
|
+
* between taking and releasing it is recovered at once (its lock is taken over in place); a live
|
|
305
|
+
* holder bounds the wait on injected time and is then refused with the record untouched. A legacy
|
|
306
|
+
* pid-less or malformed generation is recoverable too: a contender atomically creates the next owner
|
|
307
|
+
* generation, and release removes the directory only while that exact generation is still current.
|
|
308
|
+
* ponytail: observeNamedRun (supervision.ts) renames without CAS. It cannot interleave with a swap
|
|
309
|
+
* because the narrator writes nothing between reserve and claim, and no transition here swaps one.
|
|
310
|
+
*/
|
|
311
|
+
export async function casBoard(family, path, expected, next, time = SYSTEM_TIME) {
|
|
312
|
+
const raw = JSON.stringify(next) + "\n";
|
|
313
|
+
const refused = () => new OrcaError(family, `watch owner record ${path} changed underneath; swap refused and the current record kept`, "");
|
|
314
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
315
|
+
const lock = `${path}.lock`;
|
|
316
|
+
const deadline = time.now() + LOCK_WAIT_MS;
|
|
317
|
+
const ownerTarget = `${process.pid}:${randomUUID()}`;
|
|
318
|
+
let ownerEntry = "";
|
|
319
|
+
let acquiredEntries = [];
|
|
320
|
+
for (;;) {
|
|
321
|
+
try {
|
|
322
|
+
mkdirSync(lock);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error.code !== "EEXIST")
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
// The marker's directory entry and target appear as one filesystem operation. If the creator
|
|
329
|
+
// dies after mkdir but before this call, a contender claims owner.0; if both race, only one
|
|
330
|
+
// symlink wins and the loser judges that published owner before entering the CAS section.
|
|
331
|
+
const holder = lockHolder(lock);
|
|
332
|
+
if (holder?.pid !== undefined && pidLive(holder.pid)) {
|
|
333
|
+
if (time.now() >= deadline)
|
|
334
|
+
throw new OrcaError(family, `watch owner record ${path} lock not acquired; current record kept`, "");
|
|
335
|
+
await time.sleep(10);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
ownerEntry = `owner.${(holder?.gen ?? -1) + 1}`;
|
|
339
|
+
try {
|
|
340
|
+
symlinkSync(ownerTarget, join(lock, ownerEntry));
|
|
341
|
+
// Snapshot only generations that this takeover superseded, with our marker last. Release must
|
|
342
|
+
// never recursively scan: after our final unlink a contender may publish a replacement marker
|
|
343
|
+
// before rmdir, and that marker belongs to the contender, not to this generation.
|
|
344
|
+
acquiredEntries = readdirSync(lock)
|
|
345
|
+
.filter((entry) => entry === ownerEntry || lockEntryGeneration(entry) !== undefined)
|
|
346
|
+
.sort((a, b) => Number(a === ownerEntry) - Number(b === ownerEntry));
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
const code = error.code;
|
|
351
|
+
if (code !== "EEXIST" && code !== "ENOENT")
|
|
352
|
+
throw error;
|
|
353
|
+
continue; // another contender published this generation, or the prior holder just released
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
let current;
|
|
358
|
+
try {
|
|
359
|
+
current = readFileSync(path, "utf8");
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (error.code !== "ENOENT")
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
if (current !== expected)
|
|
366
|
+
throw refused();
|
|
367
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
368
|
+
writeFileSync(tmp, raw);
|
|
369
|
+
try {
|
|
370
|
+
if (expected === undefined) {
|
|
371
|
+
try {
|
|
372
|
+
linkSync(tmp, path);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
throw refused();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
renameSync(tmp, path);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
rmSync(tmp, { force: true });
|
|
384
|
+
}
|
|
385
|
+
return raw;
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
releaseBoardLock(lock, ownerEntry, ownerTarget, acquiredEntries);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const LOCK_WAIT_MS = 2_000;
|
|
392
|
+
/** Best-effort release cannot invalidate a committed CAS. Superseded entries go first and our marker
|
|
393
|
+
* goes last; after that unlink, a non-recursive rmdir either removes the empty old generation or
|
|
394
|
+
* leaves a contender's newly published marker untouched. Any abnormal filesystem error leaves the
|
|
395
|
+
* lock for the next generation's stale-owner recovery instead of escaping from the caller's finally. */
|
|
396
|
+
function releaseBoardLock(lock, ownerEntry, ownerTarget, acquiredEntries) {
|
|
397
|
+
const holder = lockHolder(lock);
|
|
398
|
+
if (holder?.entry !== ownerEntry || holder.target !== ownerTarget)
|
|
399
|
+
return;
|
|
400
|
+
for (const entry of acquiredEntries) {
|
|
401
|
+
try {
|
|
402
|
+
unlinkSync(join(lock, entry));
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
if (error.code !== "ENOENT")
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
rmdirSync(lock);
|
|
411
|
+
}
|
|
412
|
+
catch { /* ENOTEMPTY means a contender owns it; any other failure is recoverable stale state. */ }
|
|
413
|
+
}
|
|
414
|
+
/** The highest published generation. `pid*` files are accepted for recovery compatibility; new
|
|
415
|
+
* owners use atomic `owner.N -> pid:nonce` symlinks. Missing or malformed contents return a
|
|
416
|
+
* generation with no pid, which is stale and can only be superseded by creating the next marker. */
|
|
417
|
+
function lockHolder(lock) {
|
|
418
|
+
try {
|
|
419
|
+
const candidates = readdirSync(lock).flatMap((entry) => {
|
|
420
|
+
const owner = /^owner\.(\d+)$/.exec(entry);
|
|
421
|
+
const gen = lockEntryGeneration(entry);
|
|
422
|
+
return gen === undefined ? [] : [{ gen, entry, owner: owner !== null }];
|
|
423
|
+
}).sort((a, b) => b.gen - a.gen || Number(b.owner) - Number(a.owner));
|
|
424
|
+
const current = candidates[0];
|
|
425
|
+
if (!current)
|
|
426
|
+
return undefined;
|
|
427
|
+
let target;
|
|
428
|
+
try {
|
|
429
|
+
target = current.owner
|
|
430
|
+
? readlinkSync(join(lock, current.entry))
|
|
431
|
+
: readFileSync(join(lock, current.entry), "utf8").trim();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return { gen: current.gen, entry: current.entry };
|
|
435
|
+
}
|
|
436
|
+
const pidText = current.owner ? /^([1-9]\d*):.+$/.exec(target)?.[1] : target;
|
|
437
|
+
const pid = Number(pidText);
|
|
438
|
+
return Number.isInteger(pid) && pid > 0
|
|
439
|
+
? { gen: current.gen, entry: current.entry, target, pid }
|
|
440
|
+
: { gen: current.gen, entry: current.entry, target };
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function lockEntryGeneration(entry) {
|
|
447
|
+
const owner = /^owner\.(\d+)$/.exec(entry);
|
|
448
|
+
const legacy = /^pid(?:\.(\d+))?$/.exec(entry);
|
|
449
|
+
return owner ? Number(owner[1]) : legacy ? Number(legacy[1] ?? 0) : undefined;
|
|
450
|
+
}
|
|
451
|
+
function pidLive(pid) {
|
|
452
|
+
try {
|
|
453
|
+
process.kill(pid, 0);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
return error.code === "EPERM";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function terminalRows(env) {
|
|
461
|
+
const rows = env.result.terminals;
|
|
462
|
+
if (!Array.isArray(rows))
|
|
463
|
+
throw new OrcaError("list", "list response carries no terminals array", env.raw);
|
|
464
|
+
return rows.filter((r) => typeof r === "object" && r !== null);
|
|
465
|
+
}
|
|
466
|
+
function listsHandle(env, handle) {
|
|
467
|
+
return terminalRows(env).some((r) => str(r.handle) === handle);
|
|
468
|
+
}
|
|
469
|
+
/** A handle listed by a different runtime is not the recorded pane — Orca handles are runtime-scoped. */
|
|
470
|
+
function listedOnRuntime(env, handle, runtimeId) {
|
|
471
|
+
return typeof runtimeId === "string" && env.runtimeId === runtimeId && listsHandle(env, handle);
|
|
472
|
+
}
|
|
473
|
+
function terminalTabId(env, handle) {
|
|
474
|
+
for (const row of terminalRows(env)) {
|
|
475
|
+
if (str(row.handle) === handle)
|
|
476
|
+
return str(row.tabId);
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Orca 1.4.200 split contract: child at `result.split.handle`, parent tab at `result.split.tabId`.
|
|
482
|
+
* A receipt that only happens to contain a handle is malformed — it does not establish that the
|
|
483
|
+
* child belongs to the launching terminal's tab.
|
|
484
|
+
*/
|
|
485
|
+
function splitReceipt(env) {
|
|
486
|
+
const receipt = env.result.split;
|
|
487
|
+
if (typeof receipt !== "object" || receipt === null || Array.isArray(receipt)) {
|
|
488
|
+
throw new OrcaError("split", "split receipt is unknown, malformed or handle-less", env.raw);
|
|
489
|
+
}
|
|
490
|
+
const rec = receipt;
|
|
491
|
+
const handle = str(rec.handle);
|
|
492
|
+
const tabId = str(rec.tabId);
|
|
493
|
+
if (!handle || !tabId) {
|
|
494
|
+
throw new OrcaError("split", "split receipt is unknown, malformed or handle-less", env.raw);
|
|
495
|
+
}
|
|
496
|
+
return { handle, tabId };
|
|
497
|
+
}
|
|
498
|
+
function watchSlot(cwd, name, handle) {
|
|
499
|
+
return { id: handle, name, cwd: canonicalWorktreePath(cwd) };
|
|
500
|
+
}
|
|
501
|
+
function errorText(error) {
|
|
502
|
+
return error instanceof Error ? error.message : String(error);
|
|
503
|
+
}
|
|
207
504
|
/** Terminal-pane handles under a layout tab node: a pane object, an array of them, or a nested
|
|
208
505
|
* group/split carrying `panes`, `first`, `second`. Only `type:"terminal"` leaves count — anything else is chrome. */
|
|
209
|
-
function
|
|
506
|
+
function collectLeaves(node, out) {
|
|
210
507
|
if (Array.isArray(node)) {
|
|
211
508
|
for (const n of node)
|
|
212
|
-
|
|
509
|
+
collectLeaves(n, out);
|
|
213
510
|
return;
|
|
214
511
|
}
|
|
215
512
|
if (typeof node !== "object" || node === null)
|
|
@@ -218,12 +515,18 @@ function collectPaneHandles(node, out) {
|
|
|
218
515
|
if (o.type === "terminal") {
|
|
219
516
|
const h = str(o.handle);
|
|
220
517
|
if (h)
|
|
221
|
-
out.push(h);
|
|
518
|
+
out.push({ handle: h, title: str(o.title) });
|
|
222
519
|
return;
|
|
223
520
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
521
|
+
collectLeaves(o.panes, out);
|
|
522
|
+
collectLeaves(o.first, out);
|
|
523
|
+
collectLeaves(o.second, out);
|
|
524
|
+
}
|
|
525
|
+
function collectPaneHandles(node, out) {
|
|
526
|
+
const leaves = [];
|
|
527
|
+
collectLeaves(node, leaves);
|
|
528
|
+
for (const l of leaves)
|
|
529
|
+
out.push(l.handle);
|
|
227
530
|
}
|
|
228
531
|
function collectTabs(node, out) {
|
|
229
532
|
if (Array.isArray(node)) {
|
|
@@ -251,6 +554,13 @@ function collectTabs(node, out) {
|
|
|
251
554
|
export function joinWrapped(raw) {
|
|
252
555
|
return raw.split("\n").map((l) => l.replace(/^[\s│|]+/, "").replace(/[\s│|]+$/, "")).join("");
|
|
253
556
|
}
|
|
557
|
+
/** The OBS-1011 add.1 capture: `status:"exited", tail:[], returnedLineCount 0` from a stream read. */
|
|
558
|
+
export function isBlindStreamPage(term) {
|
|
559
|
+
return str(term.status) === "exited"
|
|
560
|
+
&& term.source !== "screen"
|
|
561
|
+
&& Array.isArray(term.tail) && term.tail.length === 0
|
|
562
|
+
&& (term.returnedLineCount === undefined || term.returnedLineCount === 0);
|
|
563
|
+
}
|
|
254
564
|
export class OrcaDriver {
|
|
255
565
|
id = "orca";
|
|
256
566
|
interactive = true; // a visible terminal the operator can watch and answer
|
|
@@ -266,8 +576,19 @@ export class OrcaDriver {
|
|
|
266
576
|
narrate;
|
|
267
577
|
hookCoverage;
|
|
268
578
|
taskWorktrees = new Map();
|
|
579
|
+
trackedByCheckout = new Map(); // OBS-1004: checkout → the tracked worktree enclosing it
|
|
269
580
|
pendingProjects = new Map();
|
|
581
|
+
env;
|
|
582
|
+
launchingHandle;
|
|
583
|
+
serialQueue = Promise.resolve();
|
|
584
|
+
serial(fn) {
|
|
585
|
+
const next = this.serialQueue.then(fn, fn);
|
|
586
|
+
this.serialQueue = next.then(() => { }, () => { });
|
|
587
|
+
return next;
|
|
588
|
+
}
|
|
270
589
|
constructor(opts = {}) {
|
|
590
|
+
this.env = opts.env ?? process.env;
|
|
591
|
+
this.launchingHandle = opts.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE;
|
|
271
592
|
this.bin = opts.bin ?? resolveOrcaCliBinary(process.cwd(), { env: opts.env, platform: opts.platform }) ?? "orca";
|
|
272
593
|
// Config values flow into a shell here: every argv element is quoted, always.
|
|
273
594
|
this.exec = opts.exec ?? ((args, cwd, timeoutMs) => {
|
|
@@ -414,8 +735,8 @@ export class OrcaDriver {
|
|
|
414
735
|
// terminalOp proves the runtime binding before the handle goes on the wire.
|
|
415
736
|
await this.sendText(st, cmd);
|
|
416
737
|
}
|
|
417
|
-
async sendText(st, text) {
|
|
418
|
-
const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", text, "--enter"], this.cliCwd(st)), { mutating: true });
|
|
738
|
+
async sendText(st, text, waitSubmit = false) {
|
|
739
|
+
const env = await this.terminalOp("send", st, (h) => this.call("send", ["terminal", "send", "--terminal", h, "--text", text, "--enter", ...(waitSubmit ? ["--wait-submit", "15"] : [])], this.cliCwd(st)), { mutating: true });
|
|
419
740
|
// Recorded 1.4.195 send receipt: result.send = {handle, accepted, bytesWritten}. `ok:true`
|
|
420
741
|
// alone is not delivery: the accepted receipt must name this handle and account for the bytes.
|
|
421
742
|
const receipt = this.sendReceipt(env, st);
|
|
@@ -423,6 +744,7 @@ export class OrcaDriver {
|
|
|
423
744
|
if (receipt.accepted !== true || typeof receipt.bytesWritten !== "number" || receipt.bytesWritten !== expectedBytes) {
|
|
424
745
|
throw new OrcaError("send", `send receipt does not report expected byte delivery (accepted: ${JSON.stringify(receipt.accepted)}, bytesWritten: ${receipt.bytesWritten}, expected: ${expectedBytes})`, env.raw);
|
|
425
746
|
}
|
|
747
|
+
return receipt;
|
|
426
748
|
}
|
|
427
749
|
sendReceipt(env, st) {
|
|
428
750
|
const receipt = env.result.send;
|
|
@@ -437,20 +759,23 @@ export class OrcaDriver {
|
|
|
437
759
|
}
|
|
438
760
|
async create(st, cmd) {
|
|
439
761
|
await this.probeRuntime(this.cliCwd(st));
|
|
440
|
-
await this.
|
|
441
|
-
|
|
442
|
-
//
|
|
762
|
+
const tracked = await this.trackedWorktree(st.cwd);
|
|
763
|
+
st.tracked = tracked;
|
|
764
|
+
// The selector names the TRACKED worktree enclosing this slot's checkout outright — never the
|
|
765
|
+
// UI's active worktree (`active`/`current`) nor the daemon's cwd — and the command itself moves
|
|
766
|
+
// into the checkout, because Orca cannot be asked to place a terminal in a path it does not
|
|
767
|
+
// track (OBS-1004). `--command` runs inside Orca's wrapper shell, so a `cd` prefix is honoured.
|
|
443
768
|
const env = await this.call("create", [
|
|
444
|
-
"terminal", "create", "--worktree", `path:${
|
|
769
|
+
"terminal", "create", "--worktree", `path:${tracked}`, "--title", st.title, "--command", inCheckout(st.cwd, cmd),
|
|
445
770
|
], this.cliCwd(st));
|
|
446
771
|
const term = requireTerminal("create", env);
|
|
447
772
|
const handle = str(term.handle);
|
|
448
773
|
if (!handle)
|
|
449
774
|
throw new OrcaError("create", "create receipt carries no terminal handle", env.raw);
|
|
450
|
-
// Asking is not getting: the receipt says which
|
|
451
|
-
// terminal
|
|
775
|
+
// Asking is not getting: the receipt says which worktree the runtime actually resolved, and a
|
|
776
|
+
// terminal bound elsewhere has already lost the isolation this run is built on.
|
|
452
777
|
const worktree = terminalWorktree(term);
|
|
453
|
-
if (!sameWorktree(worktree,
|
|
778
|
+
if (!sameWorktree(worktree, tracked)) {
|
|
454
779
|
// `terminal create` has ALREADY launched the command in that wrong checkout, so refusing the
|
|
455
780
|
// receipt is not yet fail-closed: the agent keeps mutating it. Close the exact handle this
|
|
456
781
|
// receipt named, under the runtime that answered it (closeTerminal re-proves that identity
|
|
@@ -461,7 +786,7 @@ export class OrcaDriver {
|
|
|
461
786
|
await this.closeTerminal({ ...st, handle, runtimeId: env.runtimeId });
|
|
462
787
|
}
|
|
463
788
|
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
|
|
789
|
+
throw this.latched("create", st, `create receipt bound to ${worktree ?? "no worktree"}, not the tracked ${tracked} enclosing ${st.cwd}`, env.raw);
|
|
465
790
|
}
|
|
466
791
|
st.handle = handle;
|
|
467
792
|
// The handle is bound to the runtime identity that ANSWERED its create.
|
|
@@ -473,62 +798,30 @@ export class OrcaDriver {
|
|
|
473
798
|
}
|
|
474
799
|
}
|
|
475
800
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
801
|
+
* OBS-1004: the tracked worktree that encloses a checkout, asked ONCE of `worktree current` from
|
|
802
|
+
* inside that checkout. Orca answers the exact path when it tracks the checkout itself, the
|
|
803
|
+
* enclosing tracked clone for a git worktree the daemon added beneath it (1.4.200, verified from
|
|
804
|
+
* `.tickmarkr/worktrees.noindex/<task>`), and selector_not_found when nothing it tracks encloses
|
|
805
|
+
* the cwd — which is a driver failure, not something to wait out: Orca has no adopt verb.
|
|
480
806
|
*/
|
|
481
|
-
async
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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));
|
|
516
|
-
}
|
|
517
|
-
const waitedMs = this.time.now() - started;
|
|
518
|
-
throw new OrcaUnavailableError("worktree-current", `Orca did not adopt ${st.cwd} within ${WORKTREE_ADOPTION_TIMEOUT_MS}ms${lastReported ? ` (last answered ${lastReported})` : ""}`, `waitedMs=${waitedMs}`);
|
|
519
|
-
}
|
|
520
|
-
/** Same repo/run/narration path Herdr uses for its driver-owned dispatch-retry row. */
|
|
521
|
-
appendAdoptionWait(st, waitedMs) {
|
|
522
|
-
const owned = parseOwnedName(st.title);
|
|
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}`);
|
|
528
|
-
}
|
|
529
|
-
Journal.open(repoRoot, owned.runId, this.narrate).append("worktree-adoption-wait", owned.taskId, {
|
|
530
|
-
milliseconds: waitedMs,
|
|
531
|
-
});
|
|
807
|
+
async trackedWorktree(checkout) {
|
|
808
|
+
const cached = this.trackedByCheckout.get(checkout);
|
|
809
|
+
if (cached)
|
|
810
|
+
return cached;
|
|
811
|
+
const env = await this.call("worktree-current", ["worktree", "current"], checkout);
|
|
812
|
+
const worktree = env.result.worktree;
|
|
813
|
+
if (typeof worktree !== "object" || worktree === null || Array.isArray(worktree)) {
|
|
814
|
+
throw new OrcaError("worktree-current", "response carries no worktree record", env.raw, { runtimeId: env.runtimeId });
|
|
815
|
+
}
|
|
816
|
+
const reported = terminalWorktree(worktree);
|
|
817
|
+
if (!reported)
|
|
818
|
+
throw new OrcaError("worktree-current", "worktree record carries no path", env.raw, { runtimeId: env.runtimeId });
|
|
819
|
+
const tracked = canonicalWorktreePath(reported);
|
|
820
|
+
if (tracked !== checkout && !checkout.startsWith(`${tracked}/`)) {
|
|
821
|
+
throw new OrcaError("worktree-current", `Orca answered ${tracked}, which does not enclose ${checkout}`, env.raw, { runtimeId: env.runtimeId });
|
|
822
|
+
}
|
|
823
|
+
this.trackedByCheckout.set(checkout, tracked);
|
|
824
|
+
return tracked;
|
|
532
825
|
}
|
|
533
826
|
// ---- handle identity and restart recovery ----------------------------------------------------
|
|
534
827
|
/**
|
|
@@ -605,6 +898,65 @@ export class OrcaDriver {
|
|
|
605
898
|
return env;
|
|
606
899
|
}
|
|
607
900
|
}
|
|
901
|
+
/**
|
|
902
|
+
* FX-N01/N05/N06: under a shared enclosing worktree every task terminal lists with the same
|
|
903
|
+
* worktreePath, so the tracked path + owned title cannot tell two nested checkouts apart. The
|
|
904
|
+
* runtime's own proof is the terminal's earliest scrollback, where the create command printed a
|
|
905
|
+
* framed `TICKMARKR_CHECKOUT` line before its payload (checkoutProofLine). READ-only calls: the
|
|
906
|
+
* anchor (for `oldestCursor`), then pages from the oldest cursor until a frame is complete or the
|
|
907
|
+
* bound is hit. Every page is evidence only when the response's own identity is the candidate's:
|
|
908
|
+
* the terminal record must name `handle` and `_meta.runtimeId` must be `runtimeId` — the runtime
|
|
909
|
+
* that supplied the ownership listing — else another terminal's or another runtime's bytes were
|
|
910
|
+
* answered and nothing is proven. Proven means provesCheckout: exact canonical full-path equality
|
|
911
|
+
* of a complete frame, no other checkout named, no incomplete frame.
|
|
912
|
+
*/
|
|
913
|
+
async checkoutProven(handle, checkout, from, runtimeId) {
|
|
914
|
+
const page = async (cursor) => {
|
|
915
|
+
const env = await this.call("read", [
|
|
916
|
+
"terminal", "read", "--terminal", handle, ...(cursor === undefined ? [] : ["--cursor", cursor]), "--limit", String(this.pageLines),
|
|
917
|
+
], from);
|
|
918
|
+
if (env.runtimeId !== runtimeId) {
|
|
919
|
+
throw new OrcaError("read", `proof page answered by runtime ${env.runtimeId}, not the listing's ${runtimeId}`, env.raw);
|
|
920
|
+
}
|
|
921
|
+
const term = requireTerminal("read", env);
|
|
922
|
+
if (str(term.handle) !== handle) {
|
|
923
|
+
throw new OrcaError("read", `proof page names terminal ${str(term.handle) ?? "none"}, not the candidate ${handle}`, env.raw);
|
|
924
|
+
}
|
|
925
|
+
return term;
|
|
926
|
+
};
|
|
927
|
+
const lines = (term) => Array.isArray(term.tail) ? term.tail.filter((l) => typeof l === "string") : [];
|
|
928
|
+
try {
|
|
929
|
+
const anchor = await page();
|
|
930
|
+
let cursor = str(anchor.oldestCursor);
|
|
931
|
+
let text = cursor === undefined ? lines(anchor).join("\n") : "";
|
|
932
|
+
for (let n = 0; cursor !== undefined && n < PROOF_PAGES; n++) {
|
|
933
|
+
const term = await page(cursor);
|
|
934
|
+
text += `${lines(term).join("\n")}\n`;
|
|
935
|
+
const frames = checkoutFrames(text);
|
|
936
|
+
if (frames.complete.length > 0 && !frames.incomplete)
|
|
937
|
+
break; // whole frames, nothing dangling
|
|
938
|
+
const next = str(term.nextCursor);
|
|
939
|
+
if (term.limited !== true || next === undefined || next === cursor)
|
|
940
|
+
break;
|
|
941
|
+
cursor = next;
|
|
942
|
+
}
|
|
943
|
+
const frames = checkoutFrames(text);
|
|
944
|
+
// Reconcile has no task-checkout path after a daemon restart, but the proof itself remains
|
|
945
|
+
// an ownership record: exactly one complete, unambiguous checkout frame can only have been
|
|
946
|
+
// written by tickmarkr's create command. Slot recovery additionally requires its exact path.
|
|
947
|
+
if (checkout === undefined
|
|
948
|
+
? !frames.incomplete && frames.complete.length > 0 && new Set(frames.complete).size === 1
|
|
949
|
+
: provesCheckout(text, checkout))
|
|
950
|
+
return { proven: true, reason: "" };
|
|
951
|
+
return {
|
|
952
|
+
proven: false,
|
|
953
|
+
reason: `its scrollback ${frames.complete.length ? `names ${[...new Set(frames.complete)].join(", ")}` : "names no checkout"}${frames.incomplete ? " and carries an incomplete proof frame" : ""}`,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
return { proven: false, reason: error instanceof Error ? error.message : String(error) };
|
|
958
|
+
}
|
|
959
|
+
}
|
|
608
960
|
async recover(family, st, newRuntimeId, raw) {
|
|
609
961
|
if (st.recovering)
|
|
610
962
|
throw this.latched(family, st, "handle recovery re-entered", raw);
|
|
@@ -613,7 +965,8 @@ export class OrcaDriver {
|
|
|
613
965
|
const old = st.handle;
|
|
614
966
|
// visualLayouts is required: the owned title survives at TAB identity only, and rows carry
|
|
615
967
|
// just the shell-controlled pane title (recorded: "…probe…" at create → "bash" on the row).
|
|
616
|
-
const
|
|
968
|
+
const home = st.tracked ?? st.cwd; // the worktree Orca placed this terminal in (OBS-1004)
|
|
969
|
+
const env = await this.call("list", ["terminal", "list", "--worktree", `path:${home}`, "--include-visual-layouts", "--limit", String(LIST_LIMIT)], this.cliCwd(st));
|
|
617
970
|
const listed = env.result.terminals;
|
|
618
971
|
if (!Array.isArray(listed))
|
|
619
972
|
throw new OrcaError("list", "list response carries no terminals array", env.raw);
|
|
@@ -625,7 +978,7 @@ export class OrcaDriver {
|
|
|
625
978
|
// The worktree-authoritative rows of THIS worktree: a handle is adoptable only if a row in
|
|
626
979
|
// the slot's exact worktree backs it. A same-titled tab in another worktree is a lookalike.
|
|
627
980
|
const inWorktree = new Set(listed
|
|
628
|
-
.filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t),
|
|
981
|
+
.filter((t) => typeof t === "object" && t !== null && sameWorktree(terminalWorktree(t), home))
|
|
629
982
|
.map((t) => str(t.handle))
|
|
630
983
|
.filter((h) => h !== undefined));
|
|
631
984
|
// Exactly one tab carrying the FULL owned title, resolving to exactly one terminal pane.
|
|
@@ -636,7 +989,7 @@ export class OrcaDriver {
|
|
|
636
989
|
if (typeof layout !== "object" || layout === null)
|
|
637
990
|
continue;
|
|
638
991
|
const lo = layout;
|
|
639
|
-
if (!sameWorktree(terminalWorktree(lo),
|
|
992
|
+
if (!sameWorktree(terminalWorktree(lo), home))
|
|
640
993
|
continue; // another worktree's tabs are never candidates
|
|
641
994
|
const tabs = [];
|
|
642
995
|
collectTabs(lo.root, tabs);
|
|
@@ -649,14 +1002,14 @@ export class OrcaDriver {
|
|
|
649
1002
|
}
|
|
650
1003
|
}
|
|
651
1004
|
if (ownedTabs.length === 0) {
|
|
652
|
-
throw this.latched(family, st, `no tab in ${
|
|
1005
|
+
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
1006
|
}
|
|
654
1007
|
if (ownedTabs.length > 1) {
|
|
655
|
-
throw this.latched(family, st, `${ownedTabs.length} tabs in ${
|
|
1008
|
+
throw this.latched(family, st, `${ownedTabs.length} tabs in ${home} carry the owned title ${st.title} — ambiguous`, env.raw);
|
|
656
1009
|
}
|
|
657
1010
|
const panes = ownedTabs[0].filter((h) => inWorktree.has(h));
|
|
658
1011
|
if (panes.length !== 1) {
|
|
659
|
-
throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${
|
|
1012
|
+
throw this.latched(family, st, `the owned tab resolves to ${panes.length} terminals in ${home}`, env.raw);
|
|
660
1013
|
}
|
|
661
1014
|
const handle = panes[0];
|
|
662
1015
|
if (handle === old) {
|
|
@@ -664,6 +1017,14 @@ export class OrcaDriver {
|
|
|
664
1017
|
// which terminal it addresses, so it is never adopted.
|
|
665
1018
|
throw this.latched(family, st, `replacement handle ${handle} is the old handle value reused by runtime ${newRuntimeId ?? env.runtimeId ?? "unknown"}`, env.raw);
|
|
666
1019
|
}
|
|
1020
|
+
if (st.tracked !== undefined && st.tracked !== st.cwd) {
|
|
1021
|
+
// FX-N01: a nested checkout shares its worktreePath with every sibling task's terminal, so
|
|
1022
|
+
// the candidate must prove the checkout itself (read-only) before it is addressed as ours.
|
|
1023
|
+
const proof = await this.checkoutProven(handle, st.cwd, this.cliCwd(st), env.runtimeId);
|
|
1024
|
+
if (!proof.proven) {
|
|
1025
|
+
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);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
667
1028
|
st.handle = handle;
|
|
668
1029
|
// The list response is the identity proof for the replacement. A stale refusal may have been
|
|
669
1030
|
// produced by either side of a restart; its metadata never overrides the runtime that relisted.
|
|
@@ -727,8 +1088,35 @@ export class OrcaDriver {
|
|
|
727
1088
|
...(cursor === undefined || recovered ? [] : ["--cursor", cursor]),
|
|
728
1089
|
"--limit", String(lines),
|
|
729
1090
|
], this.cliCwd(st)), { onRecovered: () => { recovered = true; } });
|
|
1091
|
+
if (isBlindStreamPage(requireTerminal(family, env))) {
|
|
1092
|
+
return { term: await this.screenBehindBlindStream(family, st, env), raw: env.raw, recovered };
|
|
1093
|
+
}
|
|
730
1094
|
return { term: this.validated(family, st, env), raw: env.raw, recovered };
|
|
731
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* OBS-1011 add.1 / OBS-1016: the captured incident shape — a stream page answering status exited
|
|
1098
|
+
* with an empty tail on a terminal that accepted a send seconds earlier — is BLIND, not dead, when
|
|
1099
|
+
* the same handle's show record on the same runtime reports connected and not orphaned (show carries
|
|
1100
|
+
* no status field; none is demanded) and its screen read reports running. Then the rendered frame is
|
|
1101
|
+
* the terminal's bytes. Anything less — disconnected, orphaned, another handle or runtime, a screen
|
|
1102
|
+
* that is unavailable or exited — is refused as unavailable, exactly as the dead record would be.
|
|
1103
|
+
*/
|
|
1104
|
+
async screenBehindBlindStream(family, st, blind) {
|
|
1105
|
+
const handle = str(requireTerminal(family, blind).handle);
|
|
1106
|
+
if (handle !== st.handle) {
|
|
1107
|
+
throw new OrcaUnavailableError(family, `terminal record names ${handle ?? "no handle"}, not the addressed ${st.handle}`, blind.raw);
|
|
1108
|
+
}
|
|
1109
|
+
const show = await this.call("show", ["terminal", "show", "--terminal", st.handle], this.cliCwd(st));
|
|
1110
|
+
if (show.runtimeId !== st.runtimeId) {
|
|
1111
|
+
throw new OrcaUnavailableError(family, `exited-shaped stream page: show answered by runtime ${show.runtimeId}, not the bound ${st.runtimeId}`, show.raw, "exited");
|
|
1112
|
+
}
|
|
1113
|
+
this.liveShowTerm(family, st, show);
|
|
1114
|
+
const screen = await this.readScreen(st);
|
|
1115
|
+
if (screen.source !== "screen") {
|
|
1116
|
+
throw new OrcaUnavailableError(family, `exited-shaped stream page and no rendered screen for ${st.handle}`, blind.raw, "exited");
|
|
1117
|
+
}
|
|
1118
|
+
return screen.term;
|
|
1119
|
+
}
|
|
732
1120
|
/** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
|
|
733
1121
|
async readScreen(st) {
|
|
734
1122
|
const env = await this.terminalOp("status", st, (h) => this.call("read", [
|
|
@@ -976,17 +1364,26 @@ export class OrcaDriver {
|
|
|
976
1364
|
const st = this.state(slot);
|
|
977
1365
|
if (!await this.waitCondition(st, "tui-idle", 1))
|
|
978
1366
|
return false;
|
|
979
|
-
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1367
|
+
const receipt = await this.sendText(st, message, true);
|
|
1368
|
+
// OBS-1016: delivery is proven by the receipt — a prompt stage of turn_started — or by the
|
|
1369
|
+
// composer emptying on a screen read, never by stream echo: the stream is blind on a live Orca
|
|
1370
|
+
// terminal (OBS-1011 add.1), so an echo sweep fails every nudge and latches the harvest hold.
|
|
1371
|
+
const prompt = typeof receipt.prompt === "object" && receipt.prompt !== null && !Array.isArray(receipt.prompt)
|
|
1372
|
+
? receipt.prompt
|
|
1373
|
+
: {};
|
|
1374
|
+
const stages = Array.isArray(prompt.stages) ? prompt.stages : [];
|
|
1375
|
+
if (stages.includes("turn_started"))
|
|
1376
|
+
return true;
|
|
985
1377
|
const deadline = this.time.now() + NUDGE_ECHO_TIMEOUT_MS;
|
|
986
1378
|
for (;;) {
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
1379
|
+
const screen = await this.readScreen(st);
|
|
1380
|
+
// ponytail: "composer emptied" is "the text is no longer on the frame" — a TUI that keeps the
|
|
1381
|
+
// submitted turn on screen reads as undelivered until the timeout; the receipt stage is primary.
|
|
1382
|
+
if (screen.source === "screen") {
|
|
1383
|
+
const frame = this.tailText("status", screen.term, "");
|
|
1384
|
+
if (!frame.includes(message) && !joinWrapped(frame).includes(message))
|
|
1385
|
+
return true;
|
|
1386
|
+
}
|
|
990
1387
|
const left = deadline - this.time.now();
|
|
991
1388
|
if (left <= 0)
|
|
992
1389
|
return false;
|
|
@@ -997,12 +1394,228 @@ export class OrcaDriver {
|
|
|
997
1394
|
return false;
|
|
998
1395
|
}
|
|
999
1396
|
}
|
|
1000
|
-
async narrator(
|
|
1397
|
+
async narrator(cwd, command, runId) {
|
|
1001
1398
|
if (!runId)
|
|
1002
|
-
throw new OrcaError("
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1399
|
+
throw new OrcaError("split", "Orca narrator requires a run identity", "");
|
|
1400
|
+
const launchingHandle = (this.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE)?.trim();
|
|
1401
|
+
if (!launchingHandle)
|
|
1402
|
+
throw new OrcaError("split", "Orca narrator requires ORCA_TERMINAL_HANDLE", "");
|
|
1403
|
+
return this.serial(async () => {
|
|
1404
|
+
const name = formatOwnedName({ role: "watch", taskId: "run", attempt: 0, runId });
|
|
1405
|
+
const path = this.boardPath(cwd, runId);
|
|
1406
|
+
const kept = `indeterminate cleanup refused — owner record ${path} kept`;
|
|
1407
|
+
let current = readBoard(path, cwd, runId);
|
|
1408
|
+
if (current && (current.record.driver !== this.id || current.record.name !== name)) {
|
|
1409
|
+
// Never overwrite a record this driver did not create, unless its observer is provably gone.
|
|
1410
|
+
const { driver, pid } = current.record;
|
|
1411
|
+
if (typeof pid !== "number" || pidLive(pid)) {
|
|
1412
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: the record is held by driver ${driver} with a live or unclaimed observer; ${kept}`, "");
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
else if (current) {
|
|
1416
|
+
const state = boardState(current.record);
|
|
1417
|
+
if (state === "reserved" || state === "claimed") {
|
|
1418
|
+
// A reservation or claim with no bound pane is the placing narrator's normal intermediate
|
|
1419
|
+
// state while that narrator lives (awaiting its receipt, listing, claim or bind): refused
|
|
1420
|
+
// untouched, its observer never asked to stop. Only a DEAD placer (or one unrecorded) makes
|
|
1421
|
+
// it a crash to recover.
|
|
1422
|
+
const { placer } = current.record;
|
|
1423
|
+
if (typeof placer !== "number" || pidLive(placer)) {
|
|
1424
|
+
throw new OrcaError("split", `Orca narrator placement remains unresolved for ${name} (${state}, no bound pane) while placing narrator ${placer ?? "unrecorded"} lives; ${kept}`, "");
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
if (state === "claimed") {
|
|
1428
|
+
// The placing driver crashed between the observer's claim and its bind. The record is
|
|
1429
|
+
// replaced only once that observer is proven gone: dead outright, or live and stopped with
|
|
1430
|
+
// its acknowledgement awaited on injected time — unacknowledged, it is refused untouched.
|
|
1431
|
+
const pid = current.record.pid;
|
|
1432
|
+
if (pidLive(pid)) {
|
|
1433
|
+
try {
|
|
1434
|
+
await stopWatchBoard(current.record, this.time);
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: claimed board's live observer unacknowledged; ${kept}; ${errorText(error)}`, "");
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const retired = { ...current.record, retired: true };
|
|
1441
|
+
current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
|
|
1442
|
+
}
|
|
1443
|
+
if (state === "reserved") {
|
|
1444
|
+
// The placer died before any claim: no observer exists, its split pane (if any) is unknown
|
|
1445
|
+
// to the record. Tombstoned in place so the generic tail below replaces it.
|
|
1446
|
+
const retired = { ...current.record, retired: true };
|
|
1447
|
+
current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
|
|
1448
|
+
}
|
|
1449
|
+
if (state === "bound") {
|
|
1450
|
+
// The bound record is the durable truth of placement: the child handle PLUS the split
|
|
1451
|
+
// envelope's runtime identity. A handle listed by a later runtime is a different pane.
|
|
1452
|
+
if (listedOnRuntime(await this.listAll(cwd), current.record.pane, current.record.runtimeId)) {
|
|
1453
|
+
return watchSlot(cwd, name, current.record.pane);
|
|
1454
|
+
}
|
|
1455
|
+
// Lost: this runtime no longer has that pane. Tombstone before anything else.
|
|
1456
|
+
const retired = { ...current.record, retired: true };
|
|
1457
|
+
current = { raw: await casBoard("split", path, current.raw, retired, this.time), record: retired };
|
|
1458
|
+
}
|
|
1459
|
+
// A tombstone can be an acknowledgement timeout, not completed observer cleanup. A live
|
|
1460
|
+
// observer must stop and ack on injected time before the handle-bound close; timeout keeps
|
|
1461
|
+
// the tombstone and does not replace it.
|
|
1462
|
+
try {
|
|
1463
|
+
if (typeof current.record.pid === "number" && pidLive(current.record.pid)) {
|
|
1464
|
+
await stopWatchBoard(current.record, this.time);
|
|
1465
|
+
}
|
|
1466
|
+
else {
|
|
1467
|
+
requestWatchBoardStop(current.record);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: retired board observer unacknowledged; ${kept}; ${errorText(error)}`, "");
|
|
1472
|
+
}
|
|
1473
|
+
try {
|
|
1474
|
+
await this.closeRecordedPane(cwd, name, current.record.pane, current.record.runtimeId);
|
|
1475
|
+
}
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
throw new OrcaError("split", `Orca narrator placement refused for ${name}: retired board pane ${current.record.pane} not proven closed; ${kept}; ${errorText(error)}`, "");
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
// Reserve before any command can read the token: create-only, or a swap of the exact record
|
|
1481
|
+
// judged replaceable above.
|
|
1482
|
+
const token = randomUUID();
|
|
1483
|
+
await casBoard("split", path, current?.raw, { repo: realpathSync(cwd), runId, driver: this.id, workspace: ORCA_SPACE, pane: "", name, token, placer: process.pid }, this.time);
|
|
1484
|
+
// A reservation that can never be bound — no usable receipt, or no claim — is tombstoned so the
|
|
1485
|
+
// next narrator call splits afresh instead of refusing forever. Only THIS reservation, and only
|
|
1486
|
+
// while it is still the untouched reservation; a record anyone else moved is left to them.
|
|
1487
|
+
const tombstone = async (extra = {}) => {
|
|
1488
|
+
try {
|
|
1489
|
+
const now = readBoard(path, cwd, runId);
|
|
1490
|
+
if (now?.record.token === token && boardState(now.record) === "reserved") {
|
|
1491
|
+
await casBoard("split", path, now.raw, { ...now.record, ...extra, retired: true }, this.time);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
catch { /* the record is kept as it stands; the next narrator judges it */ }
|
|
1495
|
+
};
|
|
1496
|
+
// Exactly one terminal split of the launching handle, horizontal, carrying token and command.
|
|
1497
|
+
// An unknown receipt (transport failure, refusal, unparseable output) after the verb was issued
|
|
1498
|
+
// is as indeterminate as a handle-less one: the pane may exist, so the reservation stays.
|
|
1499
|
+
const env = await this.call("split", [
|
|
1500
|
+
"terminal", "split",
|
|
1501
|
+
"--terminal", launchingHandle,
|
|
1502
|
+
"--direction", "horizontal",
|
|
1503
|
+
"--command", `${WATCH_OWNER_ENV}=${shq(token)} ${command}`,
|
|
1504
|
+
], cwd).catch(async (error) => {
|
|
1505
|
+
await tombstone();
|
|
1506
|
+
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 : "");
|
|
1507
|
+
});
|
|
1508
|
+
let child;
|
|
1509
|
+
try {
|
|
1510
|
+
child = splitReceipt(env);
|
|
1511
|
+
}
|
|
1512
|
+
catch {
|
|
1513
|
+
await tombstone();
|
|
1514
|
+
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
|
|
1515
|
+
}
|
|
1516
|
+
// Parent tabId must name the launching terminal's tab, and the child must actually appear
|
|
1517
|
+
// there. A handle-only object, a tabId for some other tab, or a handle the list does not
|
|
1518
|
+
// place in that tab is malformed — closing it would be a guessed handle.
|
|
1519
|
+
let listing;
|
|
1520
|
+
try {
|
|
1521
|
+
listing = await this.listAll(cwd);
|
|
1522
|
+
}
|
|
1523
|
+
catch (error) {
|
|
1524
|
+
await tombstone();
|
|
1525
|
+
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);
|
|
1526
|
+
}
|
|
1527
|
+
const launchingTabId = terminalTabId(listing, launchingHandle);
|
|
1528
|
+
if (!launchingTabId
|
|
1529
|
+
|| child.tabId !== launchingTabId
|
|
1530
|
+
|| child.handle === launchingHandle
|
|
1531
|
+
|| terminalTabId(listing, child.handle) !== launchingTabId) {
|
|
1532
|
+
await tombstone();
|
|
1533
|
+
throw new OrcaError("split", `Orca narrator placement failed for ${name}: split receipt is unknown, malformed or handle-less; ${kept}`, env.raw);
|
|
1534
|
+
}
|
|
1535
|
+
const childHandle = child.handle;
|
|
1536
|
+
// The narrator writes nothing until the observer's single claim is visible, so neither write
|
|
1537
|
+
// can erase the other; after it the observer never writes the record again.
|
|
1538
|
+
const claimDeadline = this.time.now() + 5000;
|
|
1539
|
+
let claim;
|
|
1540
|
+
for (;;) {
|
|
1541
|
+
const check = readBoard(path, cwd, runId);
|
|
1542
|
+
if (check?.record.token !== token)
|
|
1543
|
+
break;
|
|
1544
|
+
if (boardState(check.record) === "claimed") {
|
|
1545
|
+
claim = check;
|
|
1546
|
+
break;
|
|
1547
|
+
}
|
|
1548
|
+
if (this.time.now() > claimDeadline)
|
|
1549
|
+
break;
|
|
1550
|
+
await this.time.sleep(20);
|
|
1551
|
+
}
|
|
1552
|
+
if (!claim) {
|
|
1553
|
+
// The receipt's handle may be closed; the reservation is tombstoned naming that pane so the
|
|
1554
|
+
// next narrator can prove it gone (or close it) and split afresh.
|
|
1555
|
+
await tombstone({ pane: childHandle, runtimeId: env.runtimeId });
|
|
1556
|
+
let pane;
|
|
1557
|
+
try {
|
|
1558
|
+
await this.closeRecordedPane(cwd, name, childHandle, env.runtimeId);
|
|
1559
|
+
pane = `its pane ${childHandle} was closed`;
|
|
1560
|
+
}
|
|
1561
|
+
catch (error) {
|
|
1562
|
+
pane = `its pane ${childHandle} was not proven closed (${errorText(error)})`;
|
|
1563
|
+
}
|
|
1564
|
+
throw new OrcaError("split", `Orca narrator board unclaimed for ${name}: the observer never claimed the record (unclaimed board); ${kept}; ${pane}`, env.raw);
|
|
1565
|
+
}
|
|
1566
|
+
await casBoard("split", path, claim.raw, { ...claim.record, pane: childHandle, runtimeId: env.runtimeId }, this.time);
|
|
1567
|
+
return watchSlot(cwd, name, childHandle);
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
boardPath(cwd, runId) {
|
|
1571
|
+
return join(cwd, stateDirName(cwd), "supervision", `watch-board.${parseRunId(runId)}.json`);
|
|
1572
|
+
}
|
|
1573
|
+
/** A recorded pane (a receipt's handle bound to the split envelope's runtime, never a guess) is
|
|
1574
|
+
* gone when that runtime no longer lists it, or when a handle-bound close receipt names it.
|
|
1575
|
+
* A handle listed by a different runtime is a different pane — not closed, treated as gone. */
|
|
1576
|
+
async closeRecordedPane(cwd, name, handle, runtimeId) {
|
|
1577
|
+
const listing = await this.listAll(cwd);
|
|
1578
|
+
if (!listedOnRuntime(listing, handle, runtimeId))
|
|
1579
|
+
return;
|
|
1580
|
+
await this.closeTerminal({
|
|
1581
|
+
title: name, cwd: canonicalWorktreePath(cwd), handle, runtimeId, buf: "", recoveries: 0, recovering: false,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
/** bound → retired, decided from the record alone: it must be this driver's board for exactly this
|
|
1585
|
+
* slot's pane. Already retired is returned as it is. */
|
|
1586
|
+
async retireBoard(family, slot) {
|
|
1587
|
+
const runId = parseOwnedName(slot.name)?.runId;
|
|
1588
|
+
const path = runId ? this.boardPath(slot.cwd, runId) : undefined;
|
|
1589
|
+
const current = path && runId ? readBoard(path, slot.cwd, runId) : undefined;
|
|
1590
|
+
const r = current?.record;
|
|
1591
|
+
if (!path || !current || !r || r.driver !== this.id || r.name !== slot.name || r.pane !== slot.id || boardState(r) === "reserved" || boardState(r) === "claimed") {
|
|
1592
|
+
throw new OrcaError(family, `watch ownership unknown or foreign for ${slot.name}; existing board protected`, "");
|
|
1593
|
+
}
|
|
1594
|
+
if (r.retired)
|
|
1595
|
+
return r;
|
|
1596
|
+
const retired = { ...r, retired: true };
|
|
1597
|
+
await casBoard(family, path, current.raw, retired, this.time);
|
|
1598
|
+
return retired;
|
|
1599
|
+
}
|
|
1600
|
+
/** bound → retired first: whatever fails below, no later call answers this board again. A live
|
|
1601
|
+
* observer is asked to stop and its acknowledgement awaited on injected time before the
|
|
1602
|
+
* handle-bound close (timeout keeps the tombstone and the pane); a dead one never acknowledges,
|
|
1603
|
+
* so it is only asked. */
|
|
1604
|
+
async retireAndClose(slot) {
|
|
1605
|
+
await this.serial(async () => {
|
|
1606
|
+
const retired = await this.retireBoard("close", slot);
|
|
1607
|
+
if (typeof retired.pid === "number" && pidLive(retired.pid))
|
|
1608
|
+
await stopWatchBoard(retired, this.time);
|
|
1609
|
+
else
|
|
1610
|
+
requestWatchBoardStop(retired);
|
|
1611
|
+
await this.closeRecordedPane(slot.cwd, slot.name, slot.id, retired.runtimeId);
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
/** WB-1 seam: the daemon reports this board lost. "Lost" can be a stale beat or missing presence
|
|
1615
|
+
* under a still-live owner pid, so it is not proof of a dead observer — retirement keeps close's
|
|
1616
|
+
* acknowledgement discipline (Leg-2 T9 P1). */
|
|
1617
|
+
async retireLostWatch(slot) {
|
|
1618
|
+
await this.retireAndClose(slot);
|
|
1006
1619
|
}
|
|
1007
1620
|
async focus(target) {
|
|
1008
1621
|
const { slot, runId, taskId, attempt } = target;
|
|
@@ -1021,8 +1634,8 @@ export class OrcaDriver {
|
|
|
1021
1634
|
if (typeof layout !== "object" || layout === null)
|
|
1022
1635
|
continue;
|
|
1023
1636
|
const lo = layout;
|
|
1024
|
-
if (!
|
|
1025
|
-
continue;
|
|
1637
|
+
if (!enclosesCheckout(terminalWorktree(lo), cwd))
|
|
1638
|
+
continue; // the tracked worktree Orca placed it in (OBS-1004)
|
|
1026
1639
|
const tabs = [];
|
|
1027
1640
|
collectTabs(lo.root, tabs);
|
|
1028
1641
|
for (const tab of tabs) {
|
|
@@ -1032,9 +1645,15 @@ export class OrcaDriver {
|
|
|
1032
1645
|
}
|
|
1033
1646
|
if (handles.length !== 1)
|
|
1034
1647
|
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] &&
|
|
1648
|
+
const matches = rows.filter(row => typeof row === "object" && row !== null && str(row.handle) === handles[0] && enclosesCheckout(terminalWorktree(row), cwd));
|
|
1036
1649
|
if (matches.length !== 1)
|
|
1037
1650
|
return { status: "foreign", reason: "Terminal worktree ownership is unverified" };
|
|
1651
|
+
if (!sameWorktree(terminalWorktree(matches[0]), cwd)) {
|
|
1652
|
+
// FX-N01: the row only ENCLOSES the recorded checkout — require the terminal's own proof line.
|
|
1653
|
+
const proof = await this.checkoutProven(handles[0], cwd, cwd, env.runtimeId);
|
|
1654
|
+
if (!proof.proven)
|
|
1655
|
+
return { status: "foreign", reason: `Terminal checkout ownership is unverified: ${proof.reason}` };
|
|
1656
|
+
}
|
|
1038
1657
|
if (matches[0].connected === false || matches[0].orphaned === true)
|
|
1039
1658
|
return { status: "closed", reason: "Recorded terminal is no longer running; open task evidence" };
|
|
1040
1659
|
return { status: "unsupported", reason: "Owned terminal verified; this Orca API has no focus operation. Open task evidence with Enter" };
|
|
@@ -1053,12 +1672,15 @@ export class OrcaDriver {
|
|
|
1053
1672
|
}
|
|
1054
1673
|
await this.setWorkspaceStatus(worktree, state);
|
|
1055
1674
|
}
|
|
1056
|
-
async setWorkspaceStatus(
|
|
1675
|
+
async setWorkspaceStatus(checkout, state) {
|
|
1676
|
+
// OBS-1004: the task checkout is not an Orca selector (`selector_not_found`, recorded on run
|
|
1677
|
+
// 0004's T5); the projection lands on the tracked worktree that encloses it.
|
|
1678
|
+
const tracked = await this.trackedWorktree(checkout);
|
|
1057
1679
|
// UNRECORDED SHAPE: no Orca 1.4.195 `worktree set` receipt was captured. The shared envelope
|
|
1058
1680
|
// parser is the complete success proof here; no result payload is assumed or fabricated.
|
|
1059
1681
|
await this.call("worktree-set", [
|
|
1060
|
-
"worktree", "set", "--worktree", `path:${
|
|
1061
|
-
],
|
|
1682
|
+
"worktree", "set", "--worktree", `path:${tracked}`, "--workspace-status", state,
|
|
1683
|
+
], checkout);
|
|
1062
1684
|
}
|
|
1063
1685
|
async notify(msg, opts) {
|
|
1064
1686
|
if (opts?.tier === "routine")
|
|
@@ -1069,11 +1691,16 @@ export class OrcaDriver {
|
|
|
1069
1691
|
this.narrate = narrate;
|
|
1070
1692
|
}
|
|
1071
1693
|
async close(slot) {
|
|
1694
|
+
if (parseOwnedName(slot.name)?.role === "watch") {
|
|
1695
|
+
await this.retireAndClose(slot);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1072
1698
|
const st = this.slots.get(slot.id);
|
|
1073
1699
|
if (!st)
|
|
1074
1700
|
return;
|
|
1075
|
-
if (st.handle)
|
|
1701
|
+
if (st.handle) {
|
|
1076
1702
|
await this.closeTerminal(st);
|
|
1703
|
+
}
|
|
1077
1704
|
this.slots.delete(slot.id);
|
|
1078
1705
|
}
|
|
1079
1706
|
/**
|
|
@@ -1204,10 +1831,24 @@ export class OrcaDriver {
|
|
|
1204
1831
|
const title = str(t.title);
|
|
1205
1832
|
if (!title)
|
|
1206
1833
|
continue;
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1834
|
+
const leaves = [];
|
|
1835
|
+
collectLeaves(t.panes, leaves);
|
|
1836
|
+
const launching = (this.launchingHandle ?? this.env.ORCA_TERMINAL_HANDLE)?.trim();
|
|
1837
|
+
// A tab title belongs to the tab, not to a leaf. After the operator moves the worker
|
|
1838
|
+
// out, a foreign shell can sit alone under that owned title — so every leaf, including
|
|
1839
|
+
// the only leaf of a single-leaf tab, needs the durable checkout proof. A title that
|
|
1840
|
+
// does not parse is never a candidate.
|
|
1841
|
+
if (!parseOwnedName(title))
|
|
1842
|
+
continue;
|
|
1843
|
+
for (const leaf of leaves) {
|
|
1844
|
+
if (launching && leaf.handle === launching)
|
|
1845
|
+
continue;
|
|
1846
|
+
if (this.isRecordedWatchHandle(leaf.handle, worktree, runId, env.runtimeId))
|
|
1847
|
+
continue;
|
|
1848
|
+
if (!await this.isRecordedWorkerHandle(leaf.handle, from, env.runtimeId))
|
|
1849
|
+
continue;
|
|
1850
|
+
candidates.set(leaf.handle, { title, worktree: canonicalWorktreePath(worktree) });
|
|
1851
|
+
}
|
|
1211
1852
|
}
|
|
1212
1853
|
}
|
|
1213
1854
|
const toClose = panesToClose([...candidates].map(([paneId, c]) => ({ name: c.title, paneId, workspaceId: ORCA_SPACE })), desired, ORCA_SPACE, runId, opts);
|
|
@@ -1232,6 +1873,36 @@ export class OrcaDriver {
|
|
|
1232
1873
|
}
|
|
1233
1874
|
catch { /* cosmetic — visibility hygiene never fails the run */ }
|
|
1234
1875
|
}
|
|
1876
|
+
isRecordedWatchHandle(handle, cwd, runId, runtimeId) {
|
|
1877
|
+
const matches = (record) => !!record && record.pane === handle && record.runtimeId === runtimeId;
|
|
1878
|
+
if (runId && matches(readWatchBoard(cwd, runId)))
|
|
1879
|
+
return true;
|
|
1880
|
+
try {
|
|
1881
|
+
const dir = join(cwd, stateDirName(cwd), "supervision");
|
|
1882
|
+
if (existsSync(dir)) {
|
|
1883
|
+
for (const f of readdirSync(dir)) {
|
|
1884
|
+
if (f.startsWith("watch-board.") && f.endsWith(".json")) {
|
|
1885
|
+
const content = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
1886
|
+
if (content && typeof content === "object" && matches(content))
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
catch (error) {
|
|
1893
|
+
// Ownership uncertainty fails closed: abort this best-effort reconcile before it can treat a
|
|
1894
|
+
// recorded watch as an ordinary title-keyed worker. The outer reconcile boundary remains
|
|
1895
|
+
// cosmetic, but no close is attempted from a partial supervision-directory read.
|
|
1896
|
+
throw new OrcaError("list", `watch ownership unreadable in ${cwd}: ${errorText(error)}`, "");
|
|
1897
|
+
}
|
|
1898
|
+
return false;
|
|
1899
|
+
}
|
|
1900
|
+
async isRecordedWorkerHandle(handle, from, runtimeId) {
|
|
1901
|
+
// Do not use the driver's in-memory slots as the boundary: reconcile is also responsible for
|
|
1902
|
+
// terminals made before this driver process started. `create()` writes this proof before its
|
|
1903
|
+
// worker payload, and checkoutProven reads it from Orca rather than trusting a fixture handle.
|
|
1904
|
+
return (await this.checkoutProven(handle, undefined, from, runtimeId)).proven;
|
|
1905
|
+
}
|
|
1235
1906
|
// tickmarkr's own createWorktree stays the sole checkout authority — orca never makes worktrees.
|
|
1236
1907
|
async worktree(repo, branch, baseRef) {
|
|
1237
1908
|
const worktree = await createWorktree(repo, branch, baseRef);
|