@awebai/oats 0.39.4 → 0.40.1
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/bin/oats.mjs +101 -11
- package/docs/capabilities.md +215 -1
- package/docs/desktop-cli-api.md +159 -13
- package/docs/execution-targets.md +4 -0
- package/docs/implementation.md +19 -0
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +5 -5
- package/docs/release-notes/v0.40.0.md +181 -0
- package/docs/release-notes/v0.40.1.md +20 -0
- package/docs/schedules.md +85 -16
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +4 -1
- package/lib/core.mjs +49 -9
- package/lib/instance-events.mjs +178 -23
- package/lib/schedule-command-child.mjs +58 -0
- package/lib/schedule-command.mjs +26 -0
- package/lib/schedule.mjs +142 -37
- package/lib/servers.mjs +14 -2
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
package/docs/schedules.md
CHANGED
|
@@ -38,7 +38,7 @@ The design is in the
|
|
|
38
38
|
| `<deployment>/oats-schedules.json` | This machine's local definitions, `{version: 1, jobs: {<id>: …}}`, local triggers included (`kind: "trigger"`). |
|
|
39
39
|
| `<deployment>/.agents/automations/snapshot.json` | The workspace definitions discovered from the members. |
|
|
40
40
|
| `<deployment>/.agents/schedules/` | Run state: `state.json` (last minute and recent runs per job), `triggers.json` (polls, pending events, fired keys) and one lock directory per running job. |
|
|
41
|
-
| `~/.oats/schedules/registry.json` | The deployments this host ticks, `maxConcurrent` (default
|
|
41
|
+
| `~/.oats/schedules/registry.json` | The deployments this host ticks, `maxConcurrent` (absent: default 5 running scheduled jobs) and `triggersMaxConcurrent` (absent: no host cap on trigger-spawned live instances). The two caps are separate. |
|
|
42
42
|
|
|
43
43
|
One host lock serializes ticks, run-now, reconcile and remove. It is never
|
|
44
44
|
reclaimed by another process: a lock whose owner is gone is reported with the
|
|
@@ -48,7 +48,18 @@ directory to remove.
|
|
|
48
48
|
|
|
49
49
|
Every definition carries `id`, `enabled`, `cron`, `tz` and `kind`. `cron` has
|
|
50
50
|
five fields (minute hour day month weekday) and `tz` is a required IANA zone;
|
|
51
|
-
both are evaluated by the croner library.
|
|
51
|
+
both are evaluated by the croner library. Schedule IDs use lowercase letters,
|
|
52
|
+
digits and dashes, from 1 to 100 characters. Spawn schedules with a long ID
|
|
53
|
+
need an explicit shorter `purpose` to fit the instance-name limit below.
|
|
54
|
+
|
|
55
|
+
Any kind may carry `description`: what the job is for, in words, for the
|
|
56
|
+
people reading `oats schedule list`, `show` and the Desktop. It is one line of
|
|
57
|
+
1 to 200 characters with no control characters (no CR, LF, TAB or any other
|
|
58
|
+
C0 or C1 character, nor a Unicode line or paragraph separator); anything else
|
|
59
|
+
is `E_SCHEDULE_INVALID` with `field: "description"`. It is stored as given and
|
|
60
|
+
is informational only: it never reaches a run's argv, environment, task or
|
|
61
|
+
reconcile. A capability that registers jobs (knowledge harvest's `run-source`
|
|
62
|
+
jobs) sets it so that its command jobs can be told apart.
|
|
52
63
|
|
|
53
64
|
- **spawn** `{…, agent, agentsRoot?, repo?, backend?, purpose?, task,
|
|
54
65
|
launchConfig?, harness?, model?, yolo?, wake?}` — every due minute launches
|
|
@@ -70,7 +81,13 @@ both are evaluated by the croner library.
|
|
|
70
81
|
no shell; `oats schedule` itself is refused) in `cwd`, an existing directory
|
|
71
82
|
inside the deployment. The runner tracks any instance the command's
|
|
72
83
|
envelope names, including an independent worker it reports, until its home
|
|
73
|
-
is gone. A command's return is not task completion.
|
|
84
|
+
is gone. A command's return is not task completion. Command/operation runs and
|
|
85
|
+
workspace-spawn launches have a five-minute child timeout. The supervisor
|
|
86
|
+
sends SIGTERM to the child's process group, allows two seconds for cleanup,
|
|
87
|
+
then sends SIGKILL if the group remains. It observes the direct child's exit
|
|
88
|
+
before returning; inherited output pipes cannot hold the tick indefinitely.
|
|
89
|
+
A timeout leaves effects unconfirmed even if the child printed an envelope.
|
|
90
|
+
Spawn previews use the same bounded runner.
|
|
74
91
|
- **wake** `{…, home, message}` — every due minute inspects the instance at
|
|
75
92
|
`home`. Running: `message` is delivered once as terminal input (bracketed
|
|
76
93
|
paste plus Enter), never an interrupt. Not running: the home is started with
|
|
@@ -220,7 +237,8 @@ owner: github.com/ana
|
|
|
220
237
|
is an `E_AUTOMATION_SCHEMA` problem, never silently skipped.
|
|
221
238
|
- **The id** is `id:`, else the filename stem. The same id twice in one member
|
|
222
239
|
for one kind is `E_AUTOMATION_DUPLICATE`, naming both paths; the second file
|
|
223
|
-
is not listed.
|
|
240
|
+
is not listed. Schedule IDs allow 1 to 100 lowercase letters, digits and dashes; trigger
|
|
241
|
+
IDs allow 1 to 40. A trigger and a schedule may share an id. A member named
|
|
224
242
|
`local` is refused, because `local/<id>` names this host's own definitions.
|
|
225
243
|
- **A workspace schedule is `run: spawn` or `run: command`.** A command's
|
|
226
244
|
`cwd` is relative to the deployment and must stay inside it. `wake` and
|
|
@@ -288,6 +306,8 @@ oats schedule test <id> # dry run: where it runs, whether its soul res
|
|
|
288
306
|
oats schedule tick [--dry-run] # evaluate this deployment now; --dry-run launches nothing
|
|
289
307
|
oats schedule reconcile <id> [--clear] # resolve an attempt whose result was never recorded
|
|
290
308
|
oats schedule host install # register this deployment and install the one host timer (idempotent)
|
|
309
|
+
oats schedule host install --max-concurrent 3 --triggers-max-concurrent 2
|
|
310
|
+
oats schedule host install --max-concurrent default --triggers-max-concurrent none
|
|
291
311
|
oats schedule host status | uninstall
|
|
292
312
|
oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --wake-file spec.json
|
|
293
313
|
```
|
|
@@ -295,7 +315,26 @@ oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --w
|
|
|
295
315
|
`<id>` is `local/<id>` (or the bare id) or `<member>/<id>`. `host uninstall`
|
|
296
316
|
unregisters the deployment and removes the timer once none is registered.
|
|
297
317
|
Every `oats schedule` subcommand takes `--server <id>` instead of `--dir` to
|
|
298
|
-
run on that registered server.
|
|
318
|
+
run on that registered server. Setting or resetting host caps remotely requires
|
|
319
|
+
the destination to advertise both `schedule` and `schedule-host-caps`; an older
|
|
320
|
+
or unknown peer is refused with `E_REMOTE_INCOMPATIBLE` before host install is
|
|
321
|
+
forwarded. Upgrade OATS on the destination to use these options. A remote
|
|
322
|
+
install without cap options retains its existing behavior.
|
|
323
|
+
|
|
324
|
+
The host allows five running scheduled jobs by default. Use `host install
|
|
325
|
+
--max-concurrent N` to choose a positive integer, or `--max-concurrent default`
|
|
326
|
+
to restore the default. `--triggers-max-concurrent N` independently limits live
|
|
327
|
+
trigger-spawned instances; `none` removes that cap. Omitted flags preserve the
|
|
328
|
+
current choices. Invalid values fail with `E_BAD_ARGS` before registration or
|
|
329
|
+
timer changes. `host status` reports the effective `maxConcurrent` and
|
|
330
|
+
`triggersMaxConcurrent` (`null` when uncapped).
|
|
331
|
+
|
|
332
|
+
The registry stores explicit choices only. On the first registry read after
|
|
333
|
+
upgrade, a legacy stored `maxConcurrent: 1` without the new choice marker is
|
|
334
|
+
migrated to the default under the registry lock; other explicit values survive.
|
|
335
|
+
If you need a cap of one, run `oats schedule host install --max-concurrent 1`
|
|
336
|
+
after upgrading. That explicit choice survives later reads and reinstalls.
|
|
337
|
+
Set these values through the CLI; do not edit the registry by hand.
|
|
299
338
|
|
|
300
339
|
`oats schedule list --json` answers:
|
|
301
340
|
|
|
@@ -306,14 +345,14 @@ run on that registered server.
|
|
|
306
345
|
schedules: [ <row> ],
|
|
307
346
|
triggers: { count, command: "oats trigger list" },
|
|
308
347
|
snapshot: { takenAt, problems } | null,
|
|
309
|
-
scheduler: { installed, active, unit?, lastTick, maxConcurrent, tickIntervalSec,
|
|
348
|
+
scheduler: { installed, active, unit?, lastTick, maxConcurrent, triggersMaxConcurrent, tickIntervalSec,
|
|
310
349
|
workspace, registered, workspaces, live } }
|
|
311
350
|
```
|
|
312
351
|
|
|
313
352
|
Each row is the stored definition plus `id` (bare for a local schedule,
|
|
314
|
-
`<member>/<id>` for a workspace one), `qualifiedId`, `
|
|
315
|
-
|
|
316
|
-
`recentRuns` and `running`; an unreadable row carries `unreadable: { code,
|
|
353
|
+
`<member>/<id>` for a workspace one), `qualifiedId`, `description` (`null`
|
|
354
|
+
when there is none), `origin`, `owner`, `runsOn`, `runsHere`, `reason`,
|
|
355
|
+
`enabledHere`, `soul`, `nextDue`, `lastRun`, `recentRuns` and `running`; an unreadable row carries `unreadable: { code,
|
|
317
356
|
message }` instead of failing the list. `scheduler.active` is what the OS
|
|
318
357
|
reports about the timer. `oats trigger list --json` carries the same
|
|
319
358
|
`scheduler`. The field-level contract is in
|
|
@@ -328,12 +367,42 @@ you), `launch-failed`, `unknown`, and for wake jobs `delivered`, `started` or
|
|
|
328
367
|
`skipped`. The kernel never claims a task succeeded.
|
|
329
368
|
|
|
330
369
|
`unknown` means the launch's side effects are unconfirmed: a command timed out
|
|
331
|
-
or answered no envelope, or an attempt was never recorded. The job
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
370
|
+
or answered no envelope, or an attempt was never recorded. The job is skipped
|
|
371
|
+
until `oats schedule reconcile <id>`, which adopts only an attributable
|
|
372
|
+
receipt (a spawn job's instance, named for its minute, or the instance a
|
|
373
|
+
command's answer named). When nothing is attributable, check the roster and
|
|
374
|
+
the host by hand, then `reconcile <id> --clear` records `launch-failed` and
|
|
375
|
+
frees the slot. The unresolved attempt shows in `show` as `attempt:
|
|
376
|
+
{scheduledFor, startedAt, error?, exited?, exitStatus?, exitSignal?}`;
|
|
377
|
+
`error` is the first run's cause, which later skipped ticks keep in `lastRun`.
|
|
378
|
+
|
|
379
|
+
`oats doctor` warns about each unresolved attempt, in this deployment and in
|
|
380
|
+
the other deployments this host ticks (they share its slots). The text form
|
|
381
|
+
is `! schedule-unresolved: …`; in `--json` it is a `problems[]` item:
|
|
382
|
+
|
|
383
|
+
```text
|
|
384
|
+
{ code: "schedule-unresolved", severity: "warning", scope, id, kind,
|
|
385
|
+
scheduledFor, startedAt, ageSeconds, holdsSlot, exited, error, remedy, message }
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`holdsSlot` says whether the job counts against `maxConcurrent`. `remedy` is
|
|
389
|
+
`oats schedule reconcile <id>`, with `--clear` for a command or operation
|
|
390
|
+
whose effects no named home proves (check the roster and the host by hand
|
|
391
|
+
first), and with `--dir <scope>` for another deployment. A warning never
|
|
392
|
+
changes doctor's exit status. Workspace command kinds come from the last
|
|
393
|
+
saved automations snapshot, without a refresh or an account lookup. Unresolved
|
|
394
|
+
workspace state is reported even if its definition is no longer available.
|
|
395
|
+
|
|
396
|
+
Whether an `unknown` job keeps its host slot depends on what is still running:
|
|
397
|
+
|
|
398
|
+
- A `command` or `operation` job whose process exit the kernel observed (it
|
|
399
|
+
returned, or was stopped at the five-minute timeout) holds no slot: the
|
|
400
|
+
process runs nothing any more, and an instance it spawned has its own
|
|
401
|
+
lifecycle. Its attempt shows `exited: true` with the exit status or
|
|
402
|
+
signal, and other jobs keep running.
|
|
403
|
+
- A `spawn` job keeps its slot, which stands for the instance it may have
|
|
404
|
+
launched. So does a command whose exit was not observed (the runner threw,
|
|
405
|
+
the process never started) and a legacy attempt without exit evidence (no `exited`), until reconcile.
|
|
337
406
|
|
|
338
407
|
**Slots.** A wake job that starts a stopped home holds a launch slot until the
|
|
339
408
|
harness is proven stopped or the home is gone; delivering to a running home
|
|
@@ -344,7 +413,7 @@ continues.
|
|
|
344
413
|
|
|
345
414
|
**Changing a job.** `disable` never stops anything. `update` never touches a
|
|
346
415
|
running instance, and while a job holds a slot or has an unresolved attempt
|
|
347
|
-
only `cron`, `tz` and `
|
|
416
|
+
only `cron`, `tz`, `enabled` and `description` can change. `remove` refuses while the job's
|
|
348
417
|
instance is tracked or its effects are unresolved (`--force` forgets the job
|
|
349
418
|
without stopping anything). Retiring an instance removes the wake jobs bound
|
|
350
419
|
to its home.
|
package/docs/workspaces.md
CHANGED
|
@@ -54,7 +54,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
|
|
|
54
54
|
- git:github.com/acme/tools # a member that ALSO publishes a package (see below)
|
|
55
55
|
|
|
56
56
|
packages: # the ONLY versioned things
|
|
57
|
-
oats.framework: v1.
|
|
57
|
+
oats.framework: v1.6.0 # bare version → resolves through the official catalog
|
|
58
58
|
oats.okf: v4.1.1
|
|
59
59
|
acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
|
|
60
60
|
|
package/lib/automations.mjs
CHANGED
|
@@ -45,6 +45,8 @@ export const PLACEMENT_REASONS = Object.freeze(["host-unnamed", "assigned-elsewh
|
|
|
45
45
|
export const KIND_NAMES = Object.freeze(["trigger", "schedule"]);
|
|
46
46
|
const NEVER_SCANNED = new Set(["oats-package", ".git", "node_modules"]);
|
|
47
47
|
export const AUTOMATION_ID_RE = /^[a-z0-9-]{1,40}$/;
|
|
48
|
+
/** Schedule definition names have a wider bound than trigger names. */
|
|
49
|
+
export const SCHEDULE_NAME_RE = /^[a-z0-9-]{1,100}$/;
|
|
48
50
|
/** A model id an automation may pass to `oats spawn --model`: a provider/model id, never an option.
|
|
49
51
|
* The first character is alphanumeric (or the spawn CLI's own `@native-default`), so no value can
|
|
50
52
|
* be read as a flag (re-review B #1). `[` `]` admit a harness's context-size alias (`opus[1m]`). */
|
|
@@ -106,7 +108,8 @@ export function parseAutomationFile(desc, { stem, path, bytes, member, repoKey,
|
|
|
106
108
|
const unknown = Object.keys(doc).filter((k) => !allowed.includes(k));
|
|
107
109
|
if (unknown.length) return problem(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (a ${desc.fileKind} carries ${allowed.join(", ")})`, unknown[0]);
|
|
108
110
|
const name = doc.id === undefined ? stem : doc.id;
|
|
109
|
-
|
|
111
|
+
const namePattern = desc.kind === "schedule" ? SCHEDULE_NAME_RE : AUTOMATION_ID_RE;
|
|
112
|
+
if (typeof name !== "string" || !namePattern.test(name)) return problem(`the id (${doc.id === undefined ? "the filename stem" : "id:"} ${JSON.stringify(name)}) must be lowercase letters, digits and dashes, 1 to ${desc.kind === "schedule" ? 100 : 40} characters`, "id");
|
|
110
113
|
if (typeof doc.runsOn !== "string" || !HOST_NAME_RE.test(doc.runsOn)) return problem("runsOn: the host name that runs it (oats-local.yaml host.name: lowercase letters, digits and dashes)", "runsOn");
|
|
111
114
|
const owner = parseOwner(doc.owner);
|
|
112
115
|
if (!owner) return problem("owner: the GitHub account it acts as, <host>/<login> (e.g. github.com/acme-kb-bot)", "owner");
|
package/lib/core.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
|
|
33
33
|
} from "node:fs";
|
|
34
34
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
35
|
+
import { homedir } from "node:os";
|
|
35
36
|
import { accessSync, constants as fsConstants } from "node:fs";
|
|
36
37
|
import { recordLocalInput } from "./local-inputs.mjs";
|
|
37
38
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -40,7 +41,7 @@ import { initializeNativeHistory, prepareNativeStart } from "../packages/record/
|
|
|
40
41
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
41
42
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
42
43
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
43
|
-
import { appendEvent } from "./instance-events.mjs";
|
|
44
|
+
import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
|
|
44
45
|
import { killGroup } from "./process-group.mjs";
|
|
45
46
|
|
|
46
47
|
import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
|
|
@@ -204,7 +205,7 @@ function shIn(cwd, cmdline, timeout = 45000) {
|
|
|
204
205
|
return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
|
|
205
206
|
}
|
|
206
207
|
function shInTry(cwd, cmdline, timeout) { try { return shIn(cwd, cmdline, timeout); } catch { return undefined; } }
|
|
207
|
-
function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
208
|
+
export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
208
209
|
export function slug(s) {
|
|
209
210
|
const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
210
211
|
return r || "agent";
|
|
@@ -2008,7 +2009,7 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
|
|
|
2008
2009
|
/** Verify the harness packages that ACTIVE capabilities require for `harness`.
|
|
2009
2010
|
*
|
|
2010
2011
|
* Each comes from a declared harness-package requirement, so the capability has
|
|
2011
|
-
* stated the dependency
|
|
2012
|
+
* stated the dependency. Installing it remains the operator's responsibility.
|
|
2012
2013
|
* We verify PRESENCE and record provenance; we deliberately do NOT resolve the
|
|
2013
2014
|
* extension's entry file. pi owns that resolution — its manifest supports globs
|
|
2014
2015
|
* and exclusions, packages without a `pi` manifest use conventional directories,
|
|
@@ -2050,8 +2051,16 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
|
|
|
2050
2051
|
const status = harnessPackageStatus(harness, spec, probeEnv, probeOpts);
|
|
2051
2052
|
const mgr = HARNESS_PACKAGE_MANAGERS[harness];
|
|
2052
2053
|
const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
|
|
2053
|
-
|
|
2054
|
-
|
|
2054
|
+
// Keep the remedy in the same resource directory and selected wrapper as
|
|
2055
|
+
// the probe; quote argv, never turn a path or package selector into shell code.
|
|
2056
|
+
// Pi expands ~ against the launch HOME; relative selectors are anchored
|
|
2057
|
+
// to the probe cwd before the remedy changes to contextDir.
|
|
2058
|
+
const piResourceDir = harness === "pi" ? piAgentDir(probeEnv).replace(/^~(?=$|\/)/, () => probeEnv.HOME || homedir()) : undefined;
|
|
2059
|
+
const resourceEnv = harness === "pi" ? `PI_CODING_AGENT_DIR=${shq(resolve(piResourceDir))} `
|
|
2060
|
+
: harness === "claude" && probeEnv.CLAUDE_CONFIG_DIR ? `CLAUDE_CONFIG_DIR=${shq(resolve(probeEnv.CLAUDE_CONFIG_DIR))} ` : "";
|
|
2061
|
+
const direct = stepList.filter((a) => a.length).map((a) => resourceEnv + [bin || a[0], ...a.slice(1)].map(shq).join(" ")).join(" && ");
|
|
2062
|
+
const remedy = direct ? `ask the operator to run \`cd ${shq(contextDir)} && ${direct}\` with the selected harness's environment; OATS does not install packages during spawn or restart`
|
|
2063
|
+
: `ask the operator to install it with the selected harness's package manager`;
|
|
2055
2064
|
// `ifInstalled: true`: the row constrains a package that may be absent
|
|
2056
2065
|
// (an ambient extension must honour a contract IF it is there); absence
|
|
2057
2066
|
// satisfies it. Without the flag, absence fails as before.
|
|
@@ -3948,7 +3957,18 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
3948
3957
|
...(meta.home !== undefined && meta.home !== home ? { recordedHome: meta.home } : {}),
|
|
3949
3958
|
...(meta.instance !== undefined && meta.instance !== e.name ? { recordedInstance: meta.instance } : {}),
|
|
3950
3959
|
};
|
|
3951
|
-
|
|
3960
|
+
// Feature waiting-on-you: a producer's live claim that the instance is
|
|
3961
|
+
// blocked on a human, read only for a running row (one bounded read of
|
|
3962
|
+
// its home log); null otherwise, and null means unknown. `running` is
|
|
3963
|
+
// the window's presence, which a crashed harness's fallback shell or a
|
|
3964
|
+
// retained dead pane keeps: a row with a claim is shown only when its
|
|
3965
|
+
// session is observed running a harness, as session inspect reports it.
|
|
3966
|
+
let waitingOnYou = liveness.running === true ? liveWaiting(home) : null;
|
|
3967
|
+
if (waitingOnYou) {
|
|
3968
|
+
try { const s = instanceSessionTarget(home); if (!s.target || !harnessRunning(inspectSessionTarget(s.target))) waitingOnYou = null; }
|
|
3969
|
+
catch { waitingOnYou = null; }
|
|
3970
|
+
}
|
|
3971
|
+
return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, waitingOnYou, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
|
|
3952
3972
|
|
|
3953
3973
|
});
|
|
3954
3974
|
};
|
|
@@ -4324,6 +4344,12 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
|
|
|
4324
4344
|
* otherwise every stop or event write would read as "changed home bytes". */
|
|
4325
4345
|
const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
|
|
4326
4346
|
const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
|
|
4347
|
+
/** Harness project settings in the home are configuration, not work: the
|
|
4348
|
+
* home's `.claude/` is harness layout the kernel already shapes (the skills
|
|
4349
|
+
* alias), and capabilities keep their own entries current in its
|
|
4350
|
+
* settings.json at every launch. A retirement fingerprint ignores exactly
|
|
4351
|
+
* that path, home-relative. */
|
|
4352
|
+
const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
|
|
4327
4353
|
function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
|
|
4328
4354
|
const hash = createHash("sha256");
|
|
4329
4355
|
const rootStat = lstatSync(root);
|
|
@@ -4338,6 +4364,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
4338
4364
|
for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
4339
4365
|
if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
|
|
4340
4366
|
const childRel = rel ? join(rel, e.name) : e.name;
|
|
4367
|
+
if (instanceHome && HARNESS_HOME_SETTINGS.has(childRel)) continue;
|
|
4341
4368
|
const path = join(dir, e.name);
|
|
4342
4369
|
const st = lstatSync(path);
|
|
4343
4370
|
hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
|
|
@@ -4565,12 +4592,18 @@ function instanceSessionTarget(home) {
|
|
|
4565
4592
|
}
|
|
4566
4593
|
|
|
4567
4594
|
export function inspectInstanceSession(home) {
|
|
4568
|
-
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
|
|
4595
|
+
if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped", waitingOnYou: null };
|
|
4569
4596
|
const s = instanceSessionTarget(home);
|
|
4570
|
-
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
|
|
4571
|
-
|
|
4597
|
+
if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched", waitingOnYou: null };
|
|
4598
|
+
let observed;
|
|
4599
|
+
try { observed = inspectSessionTarget(s.target); }
|
|
4572
4600
|
catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
|
|
4601
|
+
// Feature waiting-on-you: beside `state`, whose enum is unchanged.
|
|
4602
|
+
return { home: s.home, ...observed, waitingOnYou: harnessRunning(observed) ? liveWaiting(s.home) : null };
|
|
4573
4603
|
}
|
|
4604
|
+
/** Whether an observed session runs a harness: present, and neither a
|
|
4605
|
+
* fallback shell nor a dead pane. Only such a session can be waiting on a human. */
|
|
4606
|
+
function harnessRunning(observed) { return observed?.present === true && observed.state !== "shell" && observed.state !== "stopped"; }
|
|
4574
4607
|
|
|
4575
4608
|
/** K3: quiesce one instance's session and RETAIN everything else — home,
|
|
4576
4609
|
* worktree, transcript, launch configuration — so `restart` can bring it back.
|
|
@@ -5125,6 +5158,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5125
5158
|
// Reconcile even an exited target: the independent baseline may
|
|
5126
5159
|
// already name it while metadata still names the old allocation.
|
|
5127
5160
|
const meta = readMeta();
|
|
5161
|
+
// The adopted start's session boundary, at its launch time, completed
|
|
5162
|
+
// in whichever log the interrupted start did not record it.
|
|
5163
|
+
recordStartBoundary(realHome, { startId: pending.id, startedAt: pending.startedAt, harness: pending.harness ?? meta.harness ?? null, backend: "tmux", launchConfig: pending.launch?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: "recovered" });
|
|
5128
5164
|
const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
5129
5165
|
if (st.present && st.state !== "shell") {
|
|
5130
5166
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
@@ -5305,6 +5341,10 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5305
5341
|
catch (e) { throw launchFailure(e); }
|
|
5306
5342
|
}
|
|
5307
5343
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5344
|
+
// The session exists: its boundary (lib/instance-events.mjs) is recorded
|
|
5345
|
+
// now, before the metadata, so a start whose metadata write fails still
|
|
5346
|
+
// voids the claims of the session it replaced; its adoption only completes a log it missed.
|
|
5347
|
+
recordStartBoundary(realHome, { startId: id, startedAt, harness: launchPlan?.harness || harness, backend: "tmux", launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: o.restart ? "restart" : "start" });
|
|
5308
5348
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5309
5349
|
// A transient child (for example the native-start recorder) is not proof that startup
|
|
5310
5350
|
// has finished. A later start reconciles the receipt without a watcher.
|
package/lib/instance-events.mjs
CHANGED
|
@@ -18,7 +18,15 @@
|
|
|
18
18
|
* recreated same-address instance's earlier rows are visible as earlier;
|
|
19
19
|
* - `waitingOnYou` is a producer STATE per (producer, current incarnation):
|
|
20
20
|
* that producer's latest row carrying the field decides, an explicit `false`
|
|
21
|
-
* clears, and it is computed over the full admitted read, before windowing
|
|
21
|
+
* clears, and it is computed over the full admitted read, before windowing;
|
|
22
|
+
* - a claim is LIVE only after the incarnation's latest kernel session
|
|
23
|
+
* boundary (`launched` | `restarted` | `stopped`): a claim written before
|
|
24
|
+
* the boundary belongs to a session that has ended (WAITING_BOUNDARY_KINDS).
|
|
25
|
+
*
|
|
26
|
+
* Producers write claims as `waiting` rows through `setWaiting` (the CLI's
|
|
27
|
+
* `oats instance waiting` and `oats instance attention`): data
|
|
28
|
+
* `{waitingOnYou, reason?, message?}`, appended only on a change. A claim is
|
|
29
|
+
* evidence for display, never authority: nothing in the kernel acts on it. */
|
|
22
30
|
import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
|
|
23
31
|
import { basename, dirname, join } from "node:path";
|
|
24
32
|
|
|
@@ -26,7 +34,33 @@ export const EVENTS_API = 2;
|
|
|
26
34
|
const HOME_LOG = ".oats-events.jsonl";
|
|
27
35
|
const MAX_BYTES = 4 * 1024 * 1024;
|
|
28
36
|
|
|
29
|
-
export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "launch-warning", "recomposed"];
|
|
37
|
+
export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "launch-warning", "recomposed", "waiting"];
|
|
38
|
+
|
|
39
|
+
/** Kernel rows that end or begin a session: a waiting claim older than the
|
|
40
|
+
* incarnation's latest one is not live. */
|
|
41
|
+
export const WAITING_BOUNDARY_KINDS = ["launched", "restarted", "stopped"];
|
|
42
|
+
/** The closed set of reasons a positive claim carries. */
|
|
43
|
+
export const WAITING_REASONS = ["permission", "question", "attention"];
|
|
44
|
+
/** A producer id: what `--producer` accepts (`kernel` is reserved). */
|
|
45
|
+
export const WAITING_PRODUCER_RE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
|
|
46
|
+
export const WAITING_MESSAGE_MAX = 200;
|
|
47
|
+
/** Characters a claim's message may not contain (the maintainer's set): control
|
|
48
|
+
* characters (Cc: C0, DEL, C1), the Unicode line and paragraph separators, the bidi
|
|
49
|
+
* embeddings, overrides and isolates (U+202A-202E, U+2066-2069), the invisible hiders
|
|
50
|
+
* U+200B, U+2060 and U+FEFF, and the tag characters (U+E0000-E007F). Everything else is
|
|
51
|
+
* allowed, ZWJ and ZWNJ (U+200C/D, emoji sequences, Persian and Indic text) and the marks
|
|
52
|
+
* LRM, RLM and ALM (U+200E/F, U+061C) included. The Desktop uses the identical set. */
|
|
53
|
+
const WAITING_MESSAGE_REFUSED = /[\p{Cc}\u2028\u2029\u202A-\u202E\u2066-\u2069\u200B\u2060\uFEFF\u{E0000}-\u{E007F}]/u;
|
|
54
|
+
/** A claim's message: a non-empty string of 1 to 200 code points with none of
|
|
55
|
+
* WAITING_MESSAGE_REFUSED. The writer refuses anything else; the reader turns a stored
|
|
56
|
+
* message that fails it (a hand-edited log) into null. */
|
|
57
|
+
export function validWaitingMessage(m) {
|
|
58
|
+
return typeof m === "string" && m.length > 0 && [...m].length <= WAITING_MESSAGE_MAX && !WAITING_MESSAGE_REFUSED.test(m);
|
|
59
|
+
}
|
|
60
|
+
// A reason read back from a log is shown as given only when it is one of the
|
|
61
|
+
// closed set; anything else reads as null, and the claim still counts.
|
|
62
|
+
/** A stored reason outside the closed set (a hand-edited or foreign row) reads as null. */
|
|
63
|
+
const readReason = (r) => (WAITING_REASONS.includes(r) ? r : null);
|
|
30
64
|
|
|
31
65
|
function workspaceLogPath(home) {
|
|
32
66
|
// <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
|
|
@@ -43,9 +77,12 @@ export function incarnationOf(home) {
|
|
|
43
77
|
|
|
44
78
|
/** Append one typed event. Never throws into the caller's action: an event is
|
|
45
79
|
* evidence, not authority — a failed write is reported in the return value. */
|
|
46
|
-
export function appendEvent(home, event, { workspaceOnly = false, incarnation } = {}) {
|
|
80
|
+
export function appendEvent(home, event, { workspaceOnly = false, incarnation, at } = {}) {
|
|
47
81
|
if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
|
|
48
|
-
|
|
82
|
+
// `at`: the time the fact became true, when it is recorded later (a start
|
|
83
|
+
// boundary reconciled from its receipt); readers order rows by it.
|
|
84
|
+
if (at !== undefined && (typeof at !== "string" || !Number.isFinite(Date.parse(at)))) return { ok: false, reason: `invalid event time ${at}` };
|
|
85
|
+
const row = { eventsApi: EVENTS_API, at: at ?? new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
|
|
49
86
|
const line = JSON.stringify(row) + "\n";
|
|
50
87
|
const results = [];
|
|
51
88
|
// Retirement fingerprints the home against its baseline and preserves any
|
|
@@ -96,32 +133,68 @@ function readLog(path, label) {
|
|
|
96
133
|
return { rows, unreadable, source: { path: label, status: tail ? "tail" : "ok", bytes: st.size } };
|
|
97
134
|
}
|
|
98
135
|
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
|
|
136
|
+
/** The ADDRESS's rows from the given log reads: foreign rows dropped and
|
|
137
|
+
* counted, oldest first. The logs are merged as a multiset union: a row in
|
|
138
|
+
* both logs (the same fact, written to each) is kept once, but rows that
|
|
139
|
+
* genuinely repeat within one log (a set, a clear and the same set again in
|
|
140
|
+
* one millisecond) are all kept, as many times as the log holding the most
|
|
141
|
+
* copies has them. */
|
|
142
|
+
function admit(home, reads) {
|
|
102
143
|
const instance = basename(home);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
const admitted = new Map(); const rows = []; let foreign = 0;
|
|
145
|
+
for (const read of reads) {
|
|
146
|
+
const inThisLog = new Map();
|
|
147
|
+
for (const r of read.rows) {
|
|
148
|
+
if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
|
|
149
|
+
const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
|
|
150
|
+
const n = (inThisLog.get(k) ?? 0) + 1; inThisLog.set(k, n);
|
|
151
|
+
if (n <= (admitted.get(k) ?? 0)) continue; // this copy is already in from another log
|
|
152
|
+
admitted.set(k, n); rows.push({ ...r, incarnation: r.incarnation ?? null });
|
|
153
|
+
}
|
|
110
154
|
}
|
|
111
155
|
rows.sort((x, y) => x.at.localeCompare(y.at));
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
156
|
+
return { rows, foreign };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Waiting: a producer STATE for the CURRENT incarnation, decided by that
|
|
160
|
+
* producer's latest row that carries the field, over the FULL admitted read.
|
|
161
|
+
* An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
|
|
162
|
+
* unknown stays unknown, it never resurrects an earlier incarnation's
|
|
163
|
+
* positive. A row older than the incarnation's latest kernel session
|
|
164
|
+
* boundary belongs to an ended session and is not a claim at all, nor is a
|
|
165
|
+
* row whose producer is neither `kernel` nor a valid producer id. */
|
|
166
|
+
function claimsOf(rows, incarnation) {
|
|
116
167
|
const claims = new Map();
|
|
117
|
-
|
|
118
|
-
|
|
168
|
+
if (incarnation === null) return claims;
|
|
169
|
+
const current = rows.filter((r) => r.incarnation === incarnation);
|
|
170
|
+
// Positional: rows are time-sorted with a stable sort, so rows of the same
|
|
171
|
+
// millisecond keep their append order, and a claim appended before the
|
|
172
|
+
// boundary in that millisecond is still before it.
|
|
173
|
+
const boundary = current.findLastIndex((r) => (r.producer ?? "kernel") === "kernel" && WAITING_BOUNDARY_KINDS.includes(r.kind));
|
|
174
|
+
for (const r of current.slice(boundary + 1)) {
|
|
119
175
|
if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
|
|
120
176
|
const p = r.producer ?? "kernel";
|
|
121
|
-
|
|
177
|
+
// A producer the writer would refuse (a hand-edited log) is never shown as one.
|
|
178
|
+
if (p !== "kernel" && !(typeof p === "string" && WAITING_PRODUCER_RE.test(p))) continue;
|
|
179
|
+
claims.set(p, r.data.waitingOnYou
|
|
180
|
+
? { producer: p, waiting: true, since: r.at, reason: readReason(r.data.reason), message: validWaitingMessage(r.data.message) ? r.data.message : null }
|
|
181
|
+
: { producer: p, waiting: false, since: r.at, reason: null, message: null });
|
|
122
182
|
}
|
|
183
|
+
return claims;
|
|
184
|
+
}
|
|
185
|
+
const positiveOf = (claims) => [...claims.values()].filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
|
|
186
|
+
const waitingShape = (c) => (c ? { since: c.since, producer: c.producer, reason: c.reason, message: c.message } : null);
|
|
187
|
+
|
|
188
|
+
/** Events for one instance ADDRESS, newest last, from both logs, with a bounded
|
|
189
|
+
* window; integrity is reported independently of the selected rows. */
|
|
190
|
+
export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
191
|
+
const instance = basename(home);
|
|
192
|
+
const incarnation = incarnationOf(home);
|
|
193
|
+
const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
|
|
194
|
+
const { rows, foreign } = admit(home, [a, b]);
|
|
195
|
+
const claims = claimsOf(rows, incarnation);
|
|
123
196
|
const waitingClaims = [...claims.values()];
|
|
124
|
-
const positive =
|
|
197
|
+
const positive = positiveOf(claims);
|
|
125
198
|
const filtered = since ? rows.filter((r) => r.at > since) : rows;
|
|
126
199
|
const window = filtered.slice(-limit);
|
|
127
200
|
const last = window.at(-1) ?? null;
|
|
@@ -130,13 +203,95 @@ export function readEvents(home, { limit = 200, since = null } = {}) {
|
|
|
130
203
|
truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
|
|
131
204
|
integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
|
|
132
205
|
events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer ?? "kernel", incarnation: last.incarnation } : null,
|
|
133
|
-
waitingOnYou: positive
|
|
206
|
+
waitingOnYou: waitingShape(positive),
|
|
134
207
|
waitingClaims,
|
|
135
208
|
notes: [
|
|
136
209
|
"events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
|
|
137
210
|
"this is the ADDRESS's history: rows tagged with an earlier incarnation belong to a previous instance at this address",
|
|
138
211
|
"waitingOnYou is null unless a producer reported it for the current incarnation — null means unknown, not 'not waiting'; an explicit false clears that producer's claim; an unknown current incarnation (null) admits no claim at all",
|
|
212
|
+
"a claim older than the incarnation's latest kernel launched, restarted or stopped row belongs to an ended session and is not counted",
|
|
139
213
|
"integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
|
|
140
214
|
],
|
|
141
215
|
};
|
|
142
216
|
}
|
|
217
|
+
|
|
218
|
+
/** The instance's live waiting state, `{since, producer, reason, message}` or
|
|
219
|
+
* null: what `oats status` rows and `oats session inspect` carry. The same
|
|
220
|
+
* bounded read and the same claim rule as readEvents, over the home log only
|
|
221
|
+
* (the workspace log only when the home log is absent): every row a producer
|
|
222
|
+
* or a session boundary writes goes to both. */
|
|
223
|
+
export function liveWaiting(home) {
|
|
224
|
+
const incarnation = incarnationOf(home);
|
|
225
|
+
if (incarnation === null) return null;
|
|
226
|
+
let log = readLog(join(home, HOME_LOG), "home");
|
|
227
|
+
if (log.source.status === "absent") log = readLog(workspaceLogPath(home), "workspace");
|
|
228
|
+
return waitingShape(positiveOf(claimsOf(admit(home, [log]).rows, incarnation)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const waitingError = (code, message) => Object.assign(new Error(message), { code });
|
|
232
|
+
|
|
233
|
+
/** Set or clear one producer's waiting claim on an instance home; appends a
|
|
234
|
+
* `waiting` row only when the producer's LIVE claim changes (a positive set
|
|
235
|
+
* whose reason or message differs is a change; a clear of a claim that is
|
|
236
|
+
* not positive is not). Each log is judged on its own, so a row that reached
|
|
237
|
+
* only one log earlier is repaired by the next call rather than hidden by the
|
|
238
|
+
* other log; success means both logs took the row. Validates its input
|
|
239
|
+
* (E_BAD_ARGS); a home without a readable instance.json is
|
|
240
|
+
* E_SESSION_UNKNOWN; a write either log refused is E_EVENTS_FAILED (a retry
|
|
241
|
+
* repairs it). Evidence, not authority: nothing else is touched. */
|
|
242
|
+
export function setWaiting(home, { producer, waiting, reason, message } = {}) {
|
|
243
|
+
if (typeof producer !== "string" || !WAITING_PRODUCER_RE.test(producer)) throw waitingError("E_BAD_ARGS", `--producer must match ${WAITING_PRODUCER_RE.source}`);
|
|
244
|
+
if (producer === "kernel") throw waitingError("E_BAD_ARGS", "--producer kernel is reserved for the kernel's own events");
|
|
245
|
+
if (typeof waiting !== "boolean") throw waitingError("E_BAD_ARGS", "waiting must be set or clear");
|
|
246
|
+
if (waiting) {
|
|
247
|
+
if (!WAITING_REASONS.includes(reason)) throw waitingError("E_BAD_ARGS", `--reason must be one of ${WAITING_REASONS.join(", ")}`);
|
|
248
|
+
if (message !== undefined && !validWaitingMessage(message)) throw waitingError("E_BAD_ARGS", `--message must be one line of 1 to ${WAITING_MESSAGE_MAX} characters with no control character, line separator, bidi control (U+202A-202E, U+2066-2069), U+200B, U+2060, U+FEFF or tag character`);
|
|
249
|
+
} else {
|
|
250
|
+
if (reason !== undefined) throw waitingError("E_BAD_ARGS", "--reason is for set, not clear");
|
|
251
|
+
if (message !== undefined) throw waitingError("E_BAD_ARGS", "--message is for set, not clear");
|
|
252
|
+
}
|
|
253
|
+
const incarnation = incarnationOf(home);
|
|
254
|
+
if (incarnation === null) throw waitingError("E_SESSION_UNKNOWN", `${home} is not an instance home (no readable instance.json)`);
|
|
255
|
+
const live = [readLog(join(home, HOME_LOG), "home"), readLog(workspaceLogPath(home), "workspace")]
|
|
256
|
+
.map((log) => { const c = claimsOf(admit(home, [log]).rows, incarnation).get(producer); return c?.waiting ? c : null; });
|
|
257
|
+
const agrees = (c) => (waiting ? !!c && c.reason === reason && c.message === (message ?? null) : !c);
|
|
258
|
+
const answer = (changed, claim) => ({ eventsApi: EVENTS_API, instance: basename(home), home, producer, changed, waitingOnYou: waitingShape(claim) });
|
|
259
|
+
if (live.every(agrees)) return answer(false, waiting ? live[0] : null);
|
|
260
|
+
const data = waiting ? { waitingOnYou: true, reason, ...(message !== undefined ? { message } : {}) } : { waitingOnYou: false };
|
|
261
|
+
const res = appendEvent(home, { producer, kind: "waiting", data }, { incarnation });
|
|
262
|
+
const failed = (res.results || []).filter((r) => !r.ok);
|
|
263
|
+
if (!res.ok || failed.length) throw waitingError("E_EVENTS_FAILED", `could not record the waiting claim in ${failed.map((r) => `${r.path} (${r.reason})`).join(", ") || res.reason}; retry to complete it`);
|
|
264
|
+
return answer(true, waiting ? { producer, since: res.row.at, reason, message: message ?? null } : null);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The kernel session boundary of one start receipt: a `launched` row tagged
|
|
268
|
+
* `startId`, dated at the receipt's `startedAt`, written once to each log. A
|
|
269
|
+
* start records it as soon as its session exists; recovering an interrupted
|
|
270
|
+
* start (its receipt adopted later) completes it: a log that already has the
|
|
271
|
+
* row is left alone, a log missing it gets a copy of the same row (same time,
|
|
272
|
+
* same data), and only when neither has it is the row made, still at the
|
|
273
|
+
* launch time, so a claim the new session made since is kept. `ok` is true
|
|
274
|
+
* only when every log holds the row; evidence, never authority. */
|
|
275
|
+
export function recordStartBoundary(home, { startId, startedAt, ...data }) {
|
|
276
|
+
const paths = [join(home, HOME_LOG), workspaceLogPath(home)];
|
|
277
|
+
const instance = basename(home);
|
|
278
|
+
const isIt = (r) => r.instance === instance && r.home === home && (r.producer ?? "kernel") === "kernel" && r.kind === "launched" && r.data?.startId === startId;
|
|
279
|
+
const found = paths.map((path) => readLog(path, "log").rows.find(isIt) ?? null);
|
|
280
|
+
if (found.every(Boolean)) return { ok: true, existed: true };
|
|
281
|
+
const existing = found.find(Boolean);
|
|
282
|
+
if (!existing) {
|
|
283
|
+
const res = appendEvent(home, { kind: "launched", data: { ...data, startId } }, { at: startedAt });
|
|
284
|
+
return { ...res, ok: res.ok && (res.results || []).every((r) => r.ok) };
|
|
285
|
+
}
|
|
286
|
+
// The row exactly as the other log holds it: same time, incarnation and data.
|
|
287
|
+
const line = JSON.stringify(existing) + "\n";
|
|
288
|
+
const results = paths.filter((_, i) => !found[i]).map((path) => {
|
|
289
|
+
try {
|
|
290
|
+
if (path.startsWith(home) && !existsSync(home)) return { path, ok: false, reason: "home absent" };
|
|
291
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
292
|
+
appendFileSync(path, line);
|
|
293
|
+
return { path, ok: true };
|
|
294
|
+
} catch (e) { return { path, ok: false, reason: e.message }; }
|
|
295
|
+
});
|
|
296
|
+
return { ok: results.every((r) => r.ok), repaired: true, row: existing, results };
|
|
297
|
+
}
|