@awebai/oats 0.25.9 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/bin/oats.mjs +648 -1755
- package/capabilities/oats-authoring/oats-package.json +2 -2
- package/capabilities/oats-authoring/oats.json +2 -2
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +279 -93
- package/capabilities/oats-aweb/injects/aweb.md +7 -2
- package/capabilities/oats-aweb/lib/binding-wire.mjs +89 -13
- package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
- package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
- package/capabilities/oats-aweb/oats.json +8 -4
- package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
- package/capabilities/oats-jira/oats.json +2 -2
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
- package/capabilities/oats-linear/oats.json +2 -2
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
- package/capabilities/oats-review/oats.json +3 -2
- package/docs/capabilities.md +229 -58
- package/docs/capability-manifest.schema.json +29 -9
- package/docs/configuration.md +17 -5
- package/docs/conventions.md +18 -28
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
- package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
- package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
- package/docs/design/2026-09-20-redesign-program-board.md +2 -2
- package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +34 -1
- package/docs/design/2026-09-24-phase-d-plan.md +57 -0
- package/docs/design/2026-09-25-teams-contract.md +226 -0
- package/docs/design/README.md +3 -3
- package/docs/design/launch-configurations.md +20 -16
- package/docs/design/operations-contract.md +27 -10
- package/docs/desktop-cli-api.md +604 -271
- package/docs/desktop-instance-start.md +3 -3
- package/docs/desktop.md +7 -13
- package/docs/execution-targets.md +16 -18
- package/docs/first-team.md +15 -18
- package/docs/implementation.md +31 -62
- package/docs/integrations.md +64 -33
- package/docs/knowledge-capability-authoring.md +1 -1
- package/docs/knowledge-reference/package-craft.md +10 -8
- package/docs/knowledge-theory.md +1 -1
- package/docs/knowledge.md +10 -11
- package/docs/layers.md +16 -17
- package/docs/oats-local.schema.json +30 -1
- package/docs/oats-membership.schema.json +5 -3
- package/docs/oats-package.schema.json +2 -2
- package/docs/oats-workspace.schema.json +1 -1
- package/docs/{official-marketplace.md → official-catalog.md} +15 -16
- package/docs/packages.md +76 -53
- package/docs/release-notes/v0.22.0.md +1 -1
- package/docs/release-notes/v0.23.1.md +1 -1
- package/docs/release-notes/v0.26.0.md +670 -0
- package/docs/release-notes/v0.27.0.md +100 -0
- package/docs/schedules.md +54 -132
- package/docs/servers.md +4 -4
- package/docs/soul.schema.json +11 -4
- package/docs/souls-and-instances.md +60 -47
- package/docs/workspaces.md +80 -58
- package/injects/instance-boundary.md +2 -2
- package/injects/work-attached.md +1 -1
- package/injects/work-workspace.md +2 -2
- package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
- package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
- package/lib/capability-contract.mjs +110 -0
- package/lib/config-data.mjs +2 -2
- package/lib/core.mjs +947 -5023
- package/lib/deprecation.mjs +24 -0
- package/lib/digest.mjs +12 -0
- package/lib/instance-inspect.mjs +397 -0
- package/lib/instance-lifecycle.mjs +3 -4
- package/lib/instance-resolution.mjs +212 -26
- package/lib/instruction-composition.mjs +0 -20
- package/lib/materialize.mjs +6 -4
- package/lib/operator-dispatch.mjs +33 -13
- package/lib/packages.mjs +25 -190
- package/lib/process-group.mjs +1 -1
- package/lib/provider-binding.mjs +4 -2
- package/lib/provider-reasons.mjs +3 -68
- package/lib/remote.mjs +1 -1
- package/lib/resolve.mjs +204 -68
- package/lib/schedule.mjs +136 -292
- package/lib/servers.mjs +70 -38
- package/lib/{portable-shape.mjs → shape.mjs} +4 -3
- package/lib/tree-copy.mjs +44 -0
- package/lib/workspace.mjs +132 -20
- package/package-catalog.json +6 -6
- package/package.json +1 -1
- package/packages/record/lib/session-roots.mjs +8 -6
- package/skills/integration-authoring/SKILL.md +48 -40
- package/skills/oats-getting-started/SKILL.md +105 -110
- package/skills/oats-support/SKILL.md +2 -2
- package/skills/soul-craft/SKILL.md +13 -6
- package/bin/oats-pi-sdk-host.mjs +0 -17
- package/docs/2026-09-03-architecture-proposal.md +0 -642
- package/docs/artifact-approvals.schema.json +0 -7
- package/docs/captured-invocation-context.schema.json +0 -7
- package/docs/captured-resolution.schema.json +0 -7
- package/docs/design/package-engine-contract.md +0 -813
- package/docs/design/package-runtime-api.md +0 -588
- package/docs/desktop-succession.md +0 -57
- package/docs/execution-capsule.schema.json +0 -108
- package/docs/first-team-demo.md +0 -92
- package/docs/knowledge-migration.md +0 -147
- package/docs/migration-from-oas.md +0 -103
- package/docs/oats-config.schema.json +0 -172
- package/docs/oats-lock-v3.schema.json +0 -7
- package/docs/oats-lock.schema.json +0 -175
- package/docs/operating-team-migration.md +0 -470
- package/docs/portable.schema.json +0 -2512
- package/docs/provider-check-input.schema.json +0 -7
- package/docs/rebuild-to-v2.md +0 -511
- package/docs/workspace-adoption.md +0 -74
- package/injects/framework-workspace.md +0 -7
- package/injects/local-soul.md +0 -19
- package/injects/oats-portable.md +0 -20
- package/injects/oats.md +0 -11
- package/injects/portable-instance-boundary.md +0 -39
- package/injects/portable-work-directory.md +0 -29
- package/lib/artifact-approvals.mjs +0 -120
- package/lib/artifact-tree.mjs +0 -141
- package/lib/capability-artifacts.mjs +0 -179
- package/lib/capability-execution.mjs +0 -15
- package/lib/capability-inputs.mjs +0 -39
- package/lib/capability-provenance.mjs +0 -231
- package/lib/captured-action-shape.mjs +0 -21
- package/lib/captured-admission-shape.mjs +0 -20
- package/lib/captured-binding-file.mjs +0 -36
- package/lib/captured-dispatch.mjs +0 -66
- package/lib/captured-instance-index.mjs +0 -277
- package/lib/captured-invocation-context.mjs +0 -130
- package/lib/captured-launch-request.mjs +0 -66
- package/lib/captured-operation-process.mjs +0 -15
- package/lib/captured-pi-custody.mjs +0 -29
- package/lib/captured-pi-host.mjs +0 -167
- package/lib/captured-pi-outcome.mjs +0 -172
- package/lib/captured-resolutions.mjs +0 -275
- package/lib/captured-scaffold.mjs +0 -87
- package/lib/captured-selector.mjs +0 -28
- package/lib/captured-session-backend.mjs +0 -52
- package/lib/captured-source-receipt-file.mjs +0 -72
- package/lib/helper-injection-policy.mjs +0 -104
- package/lib/legacy-lock-codec.mjs +0 -106
- package/lib/manifest-settings.mjs +0 -84
- package/lib/package-closure.mjs +0 -48
- package/lib/package-materialization.mjs +0 -83
- package/lib/pi-sdk-host.mjs +0 -229
- package/lib/portable-artifacts.mjs +0 -115
- package/lib/portable-choices.mjs +0 -82
- package/lib/portable-composition.mjs +0 -136
- package/lib/portable-digest.mjs +0 -105
- package/lib/portable-identity.mjs +0 -40
- package/lib/portable-lock.mjs +0 -117
- package/lib/portable-onboarding-request.mjs +0 -49
- package/lib/portable-onboarding.mjs +0 -256
- package/lib/portable-package-preparation.mjs +0 -188
- package/lib/portable-policy.mjs +0 -44
- package/lib/portable-soul.mjs +0 -42
- package/lib/portable-state.mjs +0 -80
- package/lib/prepare-composition.mjs +0 -170
- package/lib/prepared-bindings.mjs +0 -92
- package/lib/prepared-resources.mjs +0 -127
- package/lib/provider-binding-broker.mjs +0 -65
- package/lib/provider-binding-wire.mjs +0 -116
- package/lib/readiness.mjs +0 -225
- package/lib/repository-observation.mjs +0 -226
- package/lib/resolution-shape.mjs +0 -393
- package/lib/schedule-capsule.mjs +0 -206
- package/lib/soul-constraints.mjs +0 -40
- package/lib/source-projection.mjs +0 -84
- package/lib/source-spec.mjs +0 -189
- package/lib/workspace-definition.mjs +0 -126
- package/lib/workspace-discovery.mjs +0 -146
- package/skills/oats/SKILL.md +0 -162
- package/skills/oats-config/SKILL.md +0 -164
- package/skills/oats-packages/SKILL.md +0 -184
- package/skills/oats-portable/SKILL.md +0 -115
- package/skills/oats-portable-artifacts/SKILL.md +0 -63
package/lib/schedule.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
/** OATS schedules:
|
|
2
|
-
*
|
|
1
|
+
/** OATS schedules: workspace-scoped definitions and host-owned execution.
|
|
2
|
+
* (The captured-execution definitions of 0.24–0.25 were removed in 0.26: a
|
|
3
|
+
* stored one is reported invalid on its job and never runs.)
|
|
3
4
|
*
|
|
4
5
|
* A definition lives in <scope>/oats-schedules.json, where the scope is the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* deployment directory (the one holding oats-local.yaml, found walking up;
|
|
7
|
+
* see scheduleScopeOf), and is evaluated by the host that holds that
|
|
7
8
|
* scope: one host registry, one host lock, one host timer that runs
|
|
8
9
|
* `oats schedule tick --host` once a minute. The tick is a short-lived
|
|
9
10
|
* process; there is no daemon, no queue and no retry. Only the current
|
|
@@ -19,50 +20,49 @@
|
|
|
19
20
|
* delivered, started, skipped for wake) and never claim that a task
|
|
20
21
|
* succeeded. A launch whose side effects cannot be confirmed stays
|
|
21
22
|
* `unknown` with its slot held until `reconcile` proves what happened. */
|
|
22
|
-
import {
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
23
24
|
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
25
|
import { createHash } from "node:crypto";
|
|
25
26
|
import { homedir } from "node:os";
|
|
26
27
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
27
28
|
import { fileURLToPath } from "node:url";
|
|
28
29
|
import { Cron } from "croner";
|
|
29
|
-
import { portableScope } from "./portable-state.mjs";
|
|
30
30
|
import { loadLocal } from "./workspace.mjs";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
31
|
+
import { noteRuntimeName } from "./deprecation.mjs";
|
|
32
|
+
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
33
33
|
|
|
34
34
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
35
35
|
export const SCHEDULE_API = 2;
|
|
36
36
|
const ID_RE = /^[a-z0-9-]{1,40}$/;
|
|
37
|
-
const
|
|
37
|
+
const HARNESSES = new Set(["pi", "claude", "codex"]);
|
|
38
38
|
const BACKENDS = new Set(["tmux", "herdr"]);
|
|
39
39
|
const MESSAGE_MAX = 256 * 1024;
|
|
40
40
|
const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
|
|
41
41
|
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
42
42
|
|
|
43
43
|
export function scheduleError(code, message, extra) { return Object.assign(new Error(message), { code, ...(extra || {}) }); }
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
44
|
+
/** describe's `executionStatus` (a released wire, kept as it was): a definition is
|
|
45
|
+
* `legacy` — the only kind that runs — or `invalid` (a captured one included,
|
|
46
|
+
* since 0.26), and so is its unresolved attempt. */
|
|
46
47
|
export function scheduleExecutionStatus(def, intent, lock) {
|
|
47
|
-
const versioned =
|
|
48
|
-
|
|
49
|
-
if (!versioned) result = { kind: "legacy", capture: "unknown", migrationRequired: true };
|
|
50
|
-
else {
|
|
51
|
-
const invalid = shapeError(def);
|
|
52
|
-
if (invalid) result = { kind: "invalid", capture: "unknown", migrationRequired: true, reason: invalid };
|
|
53
|
-
else if (def.recurrencePolicy === "prepare-on-tick") result = { kind: "prepare-on-tick", capture: "not-captured", migrationRequired: false, reason: "explicit preparation runs at admission" };
|
|
54
|
-
else result = { kind: "captured", capture: "recorded", migrationRequired: false, schemaVersion: def.execution.schemaVersion, contentIntegrity: executionContentIntegrity(def.execution), resolution: def.execution.resolution };
|
|
55
|
-
}
|
|
48
|
+
const versioned = CAPTURED_SCHEDULE_KEYS.some((key) => def?.[key] !== undefined);
|
|
49
|
+
const result = versioned ? { kind: "invalid", capture: "unknown", migrationRequired: true, reason: shapeError(def) } : { kind: "legacy", capture: "unknown", migrationRequired: true };
|
|
56
50
|
if (!intent) return result;
|
|
57
51
|
try {
|
|
58
|
-
|
|
59
|
-
return { ...result, intent:
|
|
60
|
-
: { kind: "legacy", capture: "unknown", migrationRequired: true } };
|
|
52
|
+
validateAttemptExecution(intent, lock);
|
|
53
|
+
return { ...result, intent: { kind: "legacy", capture: "unknown", migrationRequired: true } };
|
|
61
54
|
} catch (error) {
|
|
62
55
|
return { ...result, intent: { kind: "invalid", capture: "unknown", migrationRequired: true, reason: error.message, errorCode: error.code } };
|
|
63
56
|
}
|
|
64
57
|
}
|
|
65
58
|
|
|
59
|
+
/** The definition keys of a captured (versioned) schedule. That path was removed in 0.26. */
|
|
60
|
+
export const CAPTURED_SCHEDULE_KEYS = Object.freeze(["definitionVersion", "recurrencePolicy", "execution", "preparation"]);
|
|
61
|
+
const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/portable path was removed in 0.26); remove this job, or re-add it without definitionVersion, recurrencePolicy, execution and preparation";
|
|
62
|
+
/** runtime → harness (0.27.0, lead call 6: read either, write new): a definition's
|
|
63
|
+
* `runtime` is its `harness`. Stored jobs are read with the new name (readDefinitions)
|
|
64
|
+
* and the next write stores it; a job naming both, differently, is invalid on its own. */
|
|
65
|
+
export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
|
|
66
66
|
export function shapeError(def) {
|
|
67
67
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
68
68
|
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
@@ -71,54 +71,30 @@ export function shapeError(def) {
|
|
|
71
71
|
if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
|
|
72
72
|
if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
|
|
73
73
|
if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (def.kind === "wake" && def.recurrencePolicy !== "capture") return "wake definitions require capture policy";
|
|
79
|
-
if (def.recurrencePolicy === "capture") {
|
|
80
|
-
if (def.preparation !== undefined) return "capture policy cannot carry prepare-on-tick input";
|
|
81
|
-
try {
|
|
82
|
-
validateExecutionTemplate(def.execution);
|
|
83
|
-
const expected = def.kind === "command"
|
|
84
|
-
? buildCommandExecutionTemplate({ cwd: def.cwd, argv: def.argv, inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman })
|
|
85
|
-
: buildWakeExecutionTemplate({ home: def.home, message: def.message,
|
|
86
|
-
executionBinding: { schemaVersion: 1, deployment: def.execution.deployment, resolution: def.execution.resolution },
|
|
87
|
-
inputRefs: def.execution.inputRefs, responsibleHuman: def.execution.responsibleHuman });
|
|
88
|
-
if (executionContentIntegrity(expected).value !== executionContentIntegrity(def.execution).value) return "execution template differs from the saved target";
|
|
89
|
-
} catch (error) { return `execution template is invalid: ${error.message}`; }
|
|
90
|
-
} else {
|
|
91
|
-
if (def.execution !== undefined) return "prepare-on-tick cannot carry a pinned execution template";
|
|
92
|
-
try { validatePreparationInput(def.preparation); } catch (error) { return `preparation input is invalid: ${error.message}`; }
|
|
93
|
-
try { commandArgvWithExecutionBinding(def.argv, { schemaVersion: 1, deployment: def.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } }); }
|
|
94
|
-
catch (error) { return `prepare-on-tick command is invalid: ${error.message}`; }
|
|
95
|
-
}
|
|
96
|
-
}
|
|
74
|
+
// A stored captured (versioned) definition is never evaluated or run (removed in 0.26): it reads
|
|
75
|
+
// as invalid, migration required, on that job only.
|
|
76
|
+
if (CAPTURED_SCHEDULE_KEYS.some((key) => def[key] !== undefined)) return CAPTURED_SCHEDULE_REMOVED;
|
|
77
|
+
if (def.runtime !== undefined && def.harness !== undefined && def.runtime !== def.harness) return HARNESS_CONFLICT;
|
|
97
78
|
return null;
|
|
98
79
|
}
|
|
99
80
|
|
|
100
81
|
// ---------------------------------------------------------------- scope
|
|
101
82
|
|
|
102
|
-
/** The one schedule-owning scope for a directory: the
|
|
103
|
-
*
|
|
104
|
-
*
|
|
83
|
+
/** The one schedule-owning scope for a directory: the deployment, i.e. the
|
|
84
|
+
* directory holding the oats-local.yaml found walking up. No deployment in
|
|
85
|
+
* reach is E_LOCAL_MISSING; an ambient OATS_ROOT / PI_AGENTS_ROOT never
|
|
86
|
+
* chooses the scope (lead decision c3-5). */
|
|
105
87
|
export function scheduleScopeOf(dir) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
/** Every agents root the scope knows: its own, member repositories', and any
|
|
115
|
-
* root that holds instance homes. */
|
|
116
|
-
export function scopeRoots(scope) {
|
|
117
|
-
const roots = new Set();
|
|
118
|
-
try { roots.add(resolve(ensureRoot(scope))); } catch { /* a scope with no agents dir */ }
|
|
119
|
-
try { for (const r of teamAgentRoots(scope)) roots.add(resolve(r)); } catch { /* not a team scope */ }
|
|
120
|
-
return [...roots];
|
|
88
|
+
let found;
|
|
89
|
+
try { found = loadLocal(dir); }
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (e?.code === "E_LOCAL_MISSING") throw scheduleError("E_LOCAL_MISSING", `schedules belong to a workspace deployment, and none is in reach of ${resolve(dir)} (no oats-local.yaml walking up; \`oats onboard\` creates one)`, { details: { dir: resolve(dir) } });
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
return dirname(found.path);
|
|
121
95
|
}
|
|
96
|
+
/** The scope's one agents root: <deployment>/agents. */
|
|
97
|
+
export function scopeRoots(scope) { return [resolve(scope, "agents")]; }
|
|
122
98
|
|
|
123
99
|
// ------------------------------------------------------------- files
|
|
124
100
|
|
|
@@ -163,6 +139,14 @@ function writeJson(path, value) {
|
|
|
163
139
|
}
|
|
164
140
|
export function readDefinitions(ws) {
|
|
165
141
|
const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
|
|
142
|
+
if (doc.jobs && typeof doc.jobs === "object" && !Array.isArray(doc.jobs)) {
|
|
143
|
+
for (const [id, def] of Object.entries(doc.jobs)) {
|
|
144
|
+
if (!def || typeof def !== "object" || Array.isArray(def) || def.runtime === undefined) continue;
|
|
145
|
+
noteRuntimeName(`schedule ${id} in ${definitionsPath(ws)} (its next save records harness)`);
|
|
146
|
+
if (def.harness === undefined) { const { runtime, ...rest } = def; doc.jobs[id] = { ...rest, harness: runtime }; }
|
|
147
|
+
else if (def.harness === def.runtime) { const { runtime, ...rest } = def; void runtime; doc.jobs[id] = rest; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
166
150
|
if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
|
|
167
151
|
return doc;
|
|
168
152
|
}
|
|
@@ -321,9 +305,18 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
321
305
|
if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim()) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
|
|
322
306
|
if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
|
|
323
307
|
if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
|
|
308
|
+
// A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
|
|
309
|
+
// MAX_INSTANCE_NAME characters, so refuse here what would fail at every firing.
|
|
310
|
+
{ const stem = out.purpose ?? id, derived = `${out.agent}-${stem}-${"0".repeat(12)}`;
|
|
311
|
+
if (derived.length > MAX_INSTANCE_NAME) throw scheduleError("E_SCHEDULE_INVALID", `${out.purpose !== undefined ? "purpose" : "id"}: runs would be named ${out.agent}-${stem}-<YYYYMMDDHHMM> (${derived.length} characters); instance names are at most ${MAX_INSTANCE_NAME} characters — shorten the ${out.purpose !== undefined ? "purpose" : "schedule id (or set a shorter purpose)"}`, { field: out.purpose !== undefined ? "purpose" : "id" }); }
|
|
324
312
|
if (typeof def.task !== "string" || !def.task.trim()) throw scheduleError("E_SCHEDULE_INVALID", "task: non-empty text", { field: "task" });
|
|
325
313
|
out.task = def.task;
|
|
326
|
-
if (def.runtime !== undefined) {
|
|
314
|
+
if (def.runtime !== undefined) {
|
|
315
|
+
if (def.harness !== undefined && def.harness !== def.runtime) throw scheduleError("E_SCHEDULE_INVALID", HARNESS_CONFLICT, { field: "runtime" });
|
|
316
|
+
noteRuntimeName(`schedule ${id}: runtime (written as harness)`);
|
|
317
|
+
def = { ...def, harness: def.runtime };
|
|
318
|
+
}
|
|
319
|
+
if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
|
|
327
320
|
if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
|
|
328
321
|
if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
|
|
329
322
|
if (def.wake !== undefined) {
|
|
@@ -337,6 +330,8 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
337
330
|
if (typeof def.cwd !== "string" || !isAbsolute(def.cwd) || !inside(ws, def.cwd) || !existsSync(def.cwd)) throw scheduleError("E_SCHEDULE_INVALID", "cwd: an existing absolute directory inside the scope", { field: "cwd" });
|
|
338
331
|
if (!Array.isArray(def.argv) || !def.argv.length || def.argv.some((a) => typeof a !== "string" || a.includes("\0")) || def.argv[0] !== "oats") throw scheduleError("E_SCHEDULE_INVALID", "argv: oats-only argument list starting with \"oats\"", { field: "argv" });
|
|
339
332
|
if (def.argv[1] === "schedule") throw scheduleError("E_SCHEDULE_INVALID", "argv: a command job may not run oats schedule (it would wait on the host lock the tick holds)", { field: "argv" });
|
|
333
|
+
const selector = def.argv.slice(1, def.argv.includes("--") ? def.argv.indexOf("--") : undefined).find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
|
|
334
|
+
if (selector) throw scheduleError("E_SCHEDULE_INVALID", `argv: ${selector.split("=")[0]} is a captured selector (the captured/portable path was removed in 0.26) — the job would be refused at every firing`, { field: "argv" });
|
|
340
335
|
out.cwd = resolve(def.cwd); out.argv = [...def.argv];
|
|
341
336
|
} else if (def.kind === "wake") {
|
|
342
337
|
if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
|
|
@@ -350,61 +345,19 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
350
345
|
if (typeof def.home !== "string" || !isAbsolute(def.home) || !inside(ws, def.home) || !existsSync(join(def.home, "instance.json"))) throw scheduleError("E_SCHEDULE_INVALID", "home: an existing instance home inside the scope", { field: "home" });
|
|
351
346
|
out.operation = def.operation; out.home = resolve(def.home);
|
|
352
347
|
} else throw scheduleError("E_SCHEDULE_INVALID", "kind: spawn, command, wake or operation", { field: "kind" });
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (!RECURRENCE_POLICIES.includes(def.recurrencePolicy)) throw scheduleError("E_SCHEDULE_INVALID", "recurrencePolicy: capture or prepare-on-tick", { field: "recurrencePolicy" });
|
|
357
|
-
if (!["command", "wake"].includes(def.kind)) throw scheduleError("E_SCHEDULE_INVALID", "versioned execution currently supports command and captured wake jobs; spawn and operation await their captured consumer adapters", { field: "recurrencePolicy" });
|
|
358
|
-
if (def.kind === "wake" && def.recurrencePolicy !== "capture") throw scheduleError("E_SCHEDULE_INVALID", "versioned wake jobs require capture policy", { field: "recurrencePolicy" });
|
|
359
|
-
out.definitionVersion = SCHEDULE_DEFINITION_VERSION;
|
|
360
|
-
out.recurrencePolicy = def.recurrencePolicy;
|
|
361
|
-
if (def.recurrencePolicy === "capture") {
|
|
362
|
-
if (def.preparation !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "preparation belongs only to prepare-on-tick", { field: "preparation" });
|
|
363
|
-
let execution;
|
|
364
|
-
try {
|
|
365
|
-
const responsibleHuman = Object.hasOwn(def, "responsibleHuman") ? def.responsibleHuman : def.execution?.responsibleHuman;
|
|
366
|
-
if (responsibleHuman === undefined) throw new Error("responsibleHuman must be explicit for captured execution (null means messaging disabled)");
|
|
367
|
-
if (def.kind === "command") execution = buildCommandExecutionTemplate({ cwd: out.cwd, argv: out.argv,
|
|
368
|
-
inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
|
|
369
|
-
else {
|
|
370
|
-
const meta = readJson(join(out.home, "instance.json"), null);
|
|
371
|
-
if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding", { field: "executionBinding" });
|
|
372
|
-
validateExecutionBinding(meta.executionBinding);
|
|
373
|
-
execution = buildWakeExecutionTemplate({ home: out.home, message: out.message, executionBinding: meta.executionBinding,
|
|
374
|
-
inputRefs: def.inputRefs ?? def.execution?.inputRefs ?? {}, responsibleHuman });
|
|
375
|
-
}
|
|
376
|
-
if (def.execution !== undefined) {
|
|
377
|
-
validateExecutionTemplate(def.execution);
|
|
378
|
-
if (executionContentIntegrity(def.execution).value !== executionContentIntegrity(execution).value) throw new Error("template differs from target, binding, inputs or responsible human");
|
|
379
|
-
}
|
|
380
|
-
} catch (error) {
|
|
381
|
-
if (error.code === "migration-required") throw error;
|
|
382
|
-
throw scheduleError("E_SCHEDULE_INVALID", `execution: ${error.message}`, { field: "execution" });
|
|
383
|
-
}
|
|
384
|
-
out.execution = execution;
|
|
385
|
-
} else {
|
|
386
|
-
if (def.execution !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "execution: prepare-on-tick captures only when a future tick is admitted", { field: "execution" });
|
|
387
|
-
try {
|
|
388
|
-
out.preparation = validatePreparationInput(def.preparation);
|
|
389
|
-
commandArgvWithExecutionBinding(out.argv, { schemaVersion: 1, deployment: out.preparation.deployment, resolution: { schemaVersion: 1, id: `sha256-${"0".repeat(64)}` } });
|
|
390
|
-
} catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `preparation: ${error.message}`, { field: "preparation" }); }
|
|
391
|
-
}
|
|
392
|
-
}
|
|
348
|
+
// Captured (versioned) schedules were removed in 0.26 (lead decisions on (e), D4).
|
|
349
|
+
const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => def[key] !== undefined);
|
|
350
|
+
if (captured.length) throw scheduleError("E_SCHEDULE_INVALID", `${captured.join(", ")}: ${CAPTURED_SCHEDULE_REMOVED}`, { field: captured[0] });
|
|
393
351
|
return out;
|
|
394
352
|
}
|
|
395
353
|
|
|
396
|
-
/** The soul a spawn job launches
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
* spawn` uses (local and persistent souls, then capability-declared
|
|
400
|
-
* agents). `repo` is the work repository and never selects a soul. */
|
|
354
|
+
/** The soul a spawn job launches, under the deployment's one agents root
|
|
355
|
+
* (a definition's agentsRoot, when given, must be that root). `repo` is the
|
|
356
|
+
* work repository and never selects a soul. */
|
|
401
357
|
export function resolveScheduledAgent(ws, def) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (def.agentsRoot && resolve(root) !== resolve(def.agentsRoot)) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${def.agentsRoot} is not an agents root (resolved to ${root})`, { field: "agentsRoot" });
|
|
406
|
-
let agent = findAgent(root, def.agent);
|
|
407
|
-
if (!agent) { try { agent = findCapabilityAgent(dirname(root), root, def.agent); } catch { agent = undefined; } }
|
|
358
|
+
const [root] = scopeRoots(ws);
|
|
359
|
+
if (def.agentsRoot && resolve(def.agentsRoot) !== root) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: ${def.agentsRoot} is not this deployment's agents root (${root})`, { field: "agentsRoot" });
|
|
360
|
+
const agent = findAgent(root, def.agent);
|
|
408
361
|
if (!agent) throw scheduleError("E_SCHEDULE_INVALID", `agent: no soul named ${JSON.stringify(def.agent)} under ${root}`, { field: "agent" });
|
|
409
362
|
return { root, agent };
|
|
410
363
|
}
|
|
@@ -418,13 +371,13 @@ export function nextRunAfter(def, from) { const n = cronOf(def).nextRun(from); r
|
|
|
418
371
|
/** Due exactly at this minute: the first run after (minute - 1 s) is this minute. */
|
|
419
372
|
export function dueAt(def, minute) { const n = cronOf(def).nextRun(new Date(minute.getTime() - 1000)); return !!n && n.getTime() === minute.getTime(); }
|
|
420
373
|
export function purposeSuffix(minute) { return minute.toISOString().slice(0, 16).replace(/[-T:]/g, ""); }
|
|
421
|
-
/** Ordering key for admission: when this job last launched a
|
|
374
|
+
/** Ordering key for admission: when this job last launched a harness. */
|
|
422
375
|
export function launchOrderKey(js) { return String(js?.lastLaunchedAt || ""); }
|
|
423
376
|
|
|
424
377
|
// ---------------------------------------------------------- observation
|
|
425
378
|
|
|
426
379
|
/** What a run's instance looks like now. Home gone = ended; a home that is
|
|
427
|
-
* retiring is still there (its
|
|
380
|
+
* retiring is still there (its harness may be alive pending teardown) and
|
|
428
381
|
* keeps its slot; present but no running harness = stopped (needs
|
|
429
382
|
* attention, never removed); running = active; not inspectable = unknown. */
|
|
430
383
|
export function observeHome(home, io) {
|
|
@@ -437,7 +390,7 @@ export function observeHome(home, io) {
|
|
|
437
390
|
if (retiring) return { outcome: "active", note: "retiring: home present, teardown pending" };
|
|
438
391
|
// A shell right after `session start` is that launch's startup phase
|
|
439
392
|
// until its wrapper writes the exit marker for the launch id; only then
|
|
440
|
-
// (or with no start receipt at all) is a shell a finished
|
|
393
|
+
// (or with no start receipt at all) is a shell a finished harness.
|
|
441
394
|
if (s.present && s.state === "shell" && startupInProgress(home)) return { outcome: "starting" };
|
|
442
395
|
return { outcome: "stopped" };
|
|
443
396
|
} catch (e) { return { outcome: "unknown", error: e.message }; }
|
|
@@ -452,7 +405,7 @@ function startupInProgress(home) {
|
|
|
452
405
|
}
|
|
453
406
|
|
|
454
407
|
/** All homes for an instance name across the scope's roster (every known
|
|
455
|
-
* agents root
|
|
408
|
+
* agents root). */
|
|
456
409
|
export function findHomesInScope(ws, instance) {
|
|
457
410
|
const homes = new Set();
|
|
458
411
|
for (const root of scopeRoots(ws)) {
|
|
@@ -465,8 +418,6 @@ export function findHomesInScope(ws, instance) {
|
|
|
465
418
|
const h = join(root, e.name, "instances", instance);
|
|
466
419
|
if (existsSync(join(h, "instance.json"))) homes.add(resolve(h));
|
|
467
420
|
}
|
|
468
|
-
const local = join(dirname(root), "local-agents");
|
|
469
|
-
if (existsSync(local)) for (const e of readdirSync(local, { withFileTypes: true })) { if (!e.isDirectory()) continue; const h = join(local, e.name, "instances", instance); if (existsSync(join(h, "instance.json"))) homes.add(resolve(h)); }
|
|
470
421
|
}
|
|
471
422
|
return [...homes];
|
|
472
423
|
}
|
|
@@ -485,27 +436,17 @@ function childEnv() {
|
|
|
485
436
|
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
486
437
|
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
487
438
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
488
|
-
"OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
439
|
+
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
489
440
|
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
490
441
|
]) delete env[key];
|
|
491
442
|
return env;
|
|
492
443
|
}
|
|
493
|
-
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
* spawn must go the workspace way and report E_WORKSPACE_SCHEMA, never fall
|
|
500
|
-
* back to a classic home. Returns null for a classic (bare agents root) spawn. */
|
|
501
|
-
export function workspaceSpawnContext(ws, root, agent) {
|
|
502
|
-
for (const dir of [dirname(root), ws]) {
|
|
503
|
-
try { const found = loadLocal(dir); return { kind: "local", local: found.path, dir: dirname(found.path) }; }
|
|
504
|
-
catch (e) { if (e?.code !== "E_LOCAL_MISSING") return { kind: "local", local: null, dir, error: { code: e.code || "E_WORKSPACE_SCHEMA", message: e.message } }; }
|
|
505
|
-
}
|
|
506
|
-
const stamp = agent?._dir ? join(agent._dir, ".oats-soul-source.json") : join(root, agent?.name || "", ".oats-soul-source.json");
|
|
507
|
-
if (agent?.name && existsSync(stamp)) return { kind: "soul-source", local: null, dir: dirname(root), stamp };
|
|
508
|
-
return null;
|
|
444
|
+
/** The deployment a spawn job runs in (module contract §5, decision 7): the
|
|
445
|
+
* scope's oats-local.yaml. An unreadable one is reported (E_WORKSPACE_SCHEMA)
|
|
446
|
+
* by the spawn, never answered some other way. */
|
|
447
|
+
export function workspaceSpawnContext(ws) {
|
|
448
|
+
try { const found = loadLocal(ws); return { kind: "local", local: found.path, dir: dirname(found.path) }; }
|
|
449
|
+
catch (e) { return { kind: "local", local: null, dir: ws, error: { code: e.code || "E_WORKSPACE_SCHEMA", message: e.message } }; }
|
|
509
450
|
}
|
|
510
451
|
/** A workspace spawn materializes EXACTLY like `oats spawn` — prepareInstance →
|
|
511
452
|
* ensureWorkspaceSoul → spawnInstanceAsync (resolve over the remotes, copy every
|
|
@@ -521,7 +462,7 @@ export function workspaceSpawnContext(ws, root, agent) {
|
|
|
521
462
|
function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
522
463
|
if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
|
|
523
464
|
const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
|
|
524
|
-
if (opts.
|
|
465
|
+
if (opts.harness) argv.push("--harness", opts.harness);
|
|
525
466
|
if (opts.model) argv.push("--model", opts.model);
|
|
526
467
|
if (opts.backend) argv.push("--backend", opts.backend);
|
|
527
468
|
if (opts.repo) argv.push("--repo", opts.repo);
|
|
@@ -551,20 +492,16 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
|
551
492
|
return { instance: res.instance, home: res.home, launched: res.launched, materialized: "workspace" };
|
|
552
493
|
}
|
|
553
494
|
/** Launch one spawn job. `io.spawn(root, agent, opts)` overrides the whole
|
|
554
|
-
* launcher for tests. Otherwise
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
* through the synchronous classic spawnInstance, whose failures throw here. */
|
|
495
|
+
* launcher for tests. Otherwise the kernel's own `oats spawn` runs in the
|
|
496
|
+
* deployment (workspace materialization: `.oats/modules`, module skills and
|
|
497
|
+
* injects). */
|
|
558
498
|
function launchSpawn(ws, def, minute, io) {
|
|
559
499
|
const { root, agent } = resolveScheduledAgent(ws, def);
|
|
560
500
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
|
|
561
|
-
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose,
|
|
501
|
+
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
|
|
562
502
|
let r;
|
|
563
503
|
if (io?.spawn) r = io.spawn(root, agent, opts);
|
|
564
|
-
else
|
|
565
|
-
const context = workspaceSpawnContext(ws, root, agent);
|
|
566
|
-
r = context ? spawnViaCli(ws, root, agent, opts, context, io) : spawnInstance(root, agent, opts);
|
|
567
|
-
}
|
|
504
|
+
else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
|
|
568
505
|
const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn", ...(r.materialized ? { materialized: r.materialized } : {}) };
|
|
569
506
|
if (def.wake && r.home) {
|
|
570
507
|
try { run.wakeSchedule = saveWakeForHome(ws, { instance: r.instance, home: r.home, wake: def.wake }); }
|
|
@@ -629,74 +566,21 @@ function launchCommand(ws, def, io) {
|
|
|
629
566
|
return { kind: "command", launched: envelope.ok === true, ...(instance ? { instance } : {}), ...(home ? { home, incarnation: incarnationOfHome(home) } : {}), ...(server ? { server } : {}), ...(envelope.ok ? {} : { error: envelope.error?.message || "command failed", errorCode: envelope.error?.code }) };
|
|
630
567
|
}
|
|
631
568
|
|
|
632
|
-
/**
|
|
633
|
-
*
|
|
634
|
-
|
|
635
|
-
* the preparation owner supplies its transaction API. */
|
|
636
|
-
function homeExecutionBinding(home) {
|
|
637
|
-
const meta = readJson(join(home, "instance.json"), null);
|
|
638
|
-
if (!meta?.executionBinding) throw scheduleError("migration-required", "wake target has no captured executionBinding");
|
|
639
|
-
try { return validateExecutionBinding(meta.executionBinding); }
|
|
640
|
-
catch (error) { throw scheduleError("E_SCHEDULE_INVALID", `wake target executionBinding is invalid: ${error.message}`); }
|
|
641
|
-
}
|
|
569
|
+
/** A captured attempt or job lock left by 0.24–0.25 (the captured path, removed in
|
|
570
|
+
* 0.26) is never run, adopted or released here: only removing its job clears it. */
|
|
571
|
+
const CAPTURED_ATTEMPT_REMOVED = "this job holds a captured schedule attempt (the captured/portable path was removed in 0.26); remove the job (oats schedule remove --force <id>)";
|
|
642
572
|
function validateAttemptExecution(attempt, lock) {
|
|
643
|
-
if (attempt?.execution
|
|
644
|
-
|
|
645
|
-
if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", "captured job lock has no matching admitted capsule");
|
|
646
|
-
return undefined;
|
|
647
|
-
}
|
|
648
|
-
if (attempt.schemaVersion !== SCHEDULE_ATTEMPT_VERSION) throw scheduleError("migration-required", "scheduled attempt uses an unsupported version");
|
|
649
|
-
const execution = validateExecutionCapsule(attempt.execution);
|
|
650
|
-
if (lock && lock.executionId !== execution.executionId) throw scheduleError("E_SCHEDULE_INVALID", "job lock does not name the admitted execution intent");
|
|
651
|
-
return execution;
|
|
573
|
+
if (attempt?.execution !== undefined || attempt?.schemaVersion !== undefined) throw scheduleError("migration-required", CAPTURED_ATTEMPT_REMOVED);
|
|
574
|
+
if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", `captured job lock: ${CAPTURED_ATTEMPT_REMOVED}`);
|
|
652
575
|
}
|
|
653
576
|
function validateActiveExecution(js, lock) {
|
|
654
577
|
if (lock?.unreadable) throw scheduleError("E_SCHEDULE_INVALID", "job lock custody is unreadable");
|
|
655
578
|
if (js.attempt) return validateAttemptExecution(js.attempt, lock);
|
|
656
|
-
if (lock && (js.lastRun?.execution || lock.executionId !== undefined)) {
|
|
657
|
-
return validateAttemptExecution(js.lastRun?.execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION, execution: js.lastRun.execution } : {}, lock);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
function receiptMatchesExecution(receipt, execution) {
|
|
661
|
-
return !!receipt?.execution && receipt.execution.executionId === execution.executionId
|
|
662
|
-
&& executionContentIntegrity(validateExecutionCapsule(receipt.execution)).value === executionContentIntegrity(execution).value;
|
|
663
|
-
}
|
|
664
|
-
function admitExecution(def, io, { createIntent = true } = {}) {
|
|
665
|
-
if (def.definitionVersion === undefined) return undefined;
|
|
666
|
-
if (def.definitionVersion !== SCHEDULE_DEFINITION_VERSION) throw scheduleError("migration-required", "scheduled definition uses an unsupported execution version");
|
|
667
|
-
let execution;
|
|
668
|
-
if (def.recurrencePolicy === "prepare-on-tick") {
|
|
669
|
-
if (!createIntent) return undefined; // dry-run never prepares or publishes
|
|
670
|
-
const prepare = io && Object.hasOwn(io, "prepare") ? io.prepare : prepareCapturedComposition;
|
|
671
|
-
if (typeof prepare !== "function") throw scheduleError("migration-required", "prepare-on-tick needs the preparation transaction adapter before this tick can be admitted");
|
|
672
|
-
const request = validatePreparationInput(def.preparation);
|
|
673
|
-
const requestedDeployment = portableScope(request.deployment); // Freeze identity before preparation, including platform path aliases.
|
|
674
|
-
const prepared = prepare(request);
|
|
675
|
-
if (!prepared || typeof prepared !== "object" || !prepared.executionBinding || !Object.hasOwn(prepared, "responsibleHuman")) {
|
|
676
|
-
throw scheduleError("needs-configuration", "preparation did not produce a complete executionBinding and responsibleHuman", { problems: prepared?.problems || [] });
|
|
677
|
-
}
|
|
678
|
-
const binding = validateExecutionBinding(prepared.executionBinding);
|
|
679
|
-
let sameDeployment = false;
|
|
680
|
-
try { sameDeployment = portableScope(binding.deployment) === requestedDeployment && portableScope(request.deployment) === requestedDeployment; } catch { /* absent/changed scope is not the admitted deployment */ }
|
|
681
|
-
if (!sameDeployment) throw scheduleError("E_SCHEDULE_INVALID", "prepared executionBinding changed the requested deployment");
|
|
682
|
-
binding.deployment = requestedDeployment; // Persist canonical authority, not a retargetable spelling.
|
|
683
|
-
if (prepared.resolution && prepared.resolution.id !== binding.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "prepared resolution differs from executionBinding");
|
|
684
|
-
const argv = commandArgvWithExecutionBinding(def.argv, binding);
|
|
685
|
-
execution = buildCommandExecutionTemplate({ cwd: def.cwd, argv, inputRefs: prepared.inputRefs ?? {}, responsibleHuman: prepared.responsibleHuman });
|
|
686
|
-
} else execution = validateExecutionTemplate(def.execution);
|
|
687
|
-
const action = capturedDispatchAction(execution);
|
|
688
|
-
if (action.kind === "wake") {
|
|
689
|
-
const binding = homeExecutionBinding(execution.target.home);
|
|
690
|
-
if (binding.deployment !== execution.deployment || binding.resolution.id !== execution.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "wake target executionBinding differs from its captured template");
|
|
691
|
-
}
|
|
692
|
-
const request = { deployment: execution.deployment, resolution: execution.resolution, action };
|
|
693
|
-
(io?.admit || loadCapturedDispatch)(request);
|
|
694
|
-
if (action.kind === "wake") throw scheduleError("migration-required", "captured wake lifecycle dispatch is not integrated yet");
|
|
695
|
-
return createIntent ? admitExecutionTemplate(execution) : undefined;
|
|
579
|
+
if (lock && (js.lastRun?.execution !== undefined || lock.executionId !== undefined)) validateAttemptExecution({ execution: js.lastRun?.execution }, lock);
|
|
696
580
|
}
|
|
697
581
|
/** One wake. `startIfStopped` false between due minutes (a harness that
|
|
698
582
|
* keeps exiting is not restarted every minute); `canStart` false when the
|
|
699
|
-
* host bound is reached (a new
|
|
583
|
+
* host bound is reached (a new harness takes a slot; a delivery does not). */
|
|
700
584
|
/** A failure message that reports effects the kernel could not undo or
|
|
701
585
|
* confirm (spawn compensation's "rollback INCOMPLETE", a quarantined or
|
|
702
586
|
* retained home): the launch's effects are unconfirmed, never a confirmed
|
|
@@ -715,14 +599,14 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
715
599
|
// The slot is persisted BEFORE the start call and kept on ANY start
|
|
716
600
|
// exception: an error code does not prove nothing was allocated (core
|
|
717
601
|
// can refuse while recording, after the session exists). The next
|
|
718
|
-
// observation releases it once the
|
|
602
|
+
// observation releases it once the harness is proven stopped or absent;
|
|
719
603
|
// at worst a refused start occupies a slot for one tick.
|
|
720
604
|
if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
|
|
721
605
|
try {
|
|
722
606
|
const r = (io?.start || startInstanceSession)(def.home);
|
|
723
|
-
return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true,
|
|
607
|
+
return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedHarness: true };
|
|
724
608
|
} catch (e) {
|
|
725
|
-
return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true,
|
|
609
|
+
return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedHarness: "unconfirmed" };
|
|
726
610
|
}
|
|
727
611
|
}
|
|
728
612
|
try {
|
|
@@ -737,15 +621,18 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
737
621
|
|
|
738
622
|
/** Observe the previous run of one job. The tracked identity is what was
|
|
739
623
|
* launched (the lock's or lastRun's home), never the current definition,
|
|
740
|
-
* so an edit cannot make a slot forget its
|
|
741
|
-
* while its started
|
|
742
|
-
* released when that
|
|
624
|
+
* so an edit cannot make a slot forget its harness. A wake slot is held
|
|
625
|
+
* while its started harness is active, starting, unknown or retiring and
|
|
626
|
+
* released when that harness is proven stopped (the start receipt's exit
|
|
743
627
|
* marker) or its home is gone: a persistent home outliving its process
|
|
744
628
|
* does not keep a slot. */
|
|
745
629
|
function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
630
|
+
// An invalid definition (a stored captured one included) is reported on its
|
|
631
|
+
// job by the tick and never observed.
|
|
632
|
+
if (shapeError(def)) return;
|
|
746
633
|
const lock = jobLockInfo(ws, id);
|
|
747
634
|
validateActiveExecution(js, lock); // No observation may erase invalid custody.
|
|
748
|
-
if (js.attempt
|
|
635
|
+
if (js.attempt) return; // Unconfirmed effects need reconcile.
|
|
749
636
|
const lr = js.lastRun;
|
|
750
637
|
const home = def.kind === "wake" ? (lock?.home || lr?.home || def.home) : (lock?.home || lr?.home);
|
|
751
638
|
const tracked = def.kind === "wake" ? !!lock : !!home && ["launched", "active", "starting", "stopped", "unknown"].includes(lr?.outcome);
|
|
@@ -756,7 +643,7 @@ function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
|
756
643
|
if (seen.outcome === "ended" || seen.outcome === "stopped") {
|
|
757
644
|
releaseJobLock(ws, id);
|
|
758
645
|
if (seen.outcome === "ended") delete js.pendingWake;
|
|
759
|
-
if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started
|
|
646
|
+
if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started harness ended" : "started harness stopped; slot released" };
|
|
760
647
|
}
|
|
761
648
|
return;
|
|
762
649
|
}
|
|
@@ -786,41 +673,24 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
786
673
|
// `candidates` (the host tick) means the host already observed every scope.
|
|
787
674
|
if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
|
|
788
675
|
if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
|
|
789
|
-
// Least recently LAUNCHED first: only an actual
|
|
676
|
+
// Least recently LAUNCHED first: only an actual harness launch counts, a
|
|
790
677
|
// skipped or pending job keeps its place at the front of the line.
|
|
791
678
|
const order = (candidates || Object.keys(defs.jobs)).filter((id) => Object.prototype.hasOwnProperty.call(defs.jobs, id) && (!only || id === only)).sort((a, b) => launchOrderKey(st.jobs[a]).localeCompare(launchOrderKey(st.jobs[b])) || a.localeCompare(b));
|
|
792
679
|
for (const id of order) {
|
|
793
680
|
const def = defs.jobs[id];
|
|
794
681
|
const js = stateOf(id);
|
|
795
|
-
validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
|
|
796
682
|
let due, cronError;
|
|
797
683
|
const shape = shapeError(def);
|
|
684
|
+
if (!shape) validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
|
|
798
685
|
if (shape) { cronError = shape; due = false; }
|
|
799
686
|
else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
|
|
800
687
|
if (cronError) { if (!dryRun) js.lastRun = { ...(js.lastRun || {}), outcome: "invalid", error: `definition cannot be evaluated: ${cronError}` }; record(id, "invalid", { error: cronError }); continue; }
|
|
801
|
-
// A captured wake is bound to the target home's exact executionBinding,
|
|
802
|
-
// but remains gated until lifecycle exposes captured wake dispatch. Keep
|
|
803
|
-
// this before both pending delivery and due-start branches so neither can
|
|
804
|
-
// fall through to the legacy home path.
|
|
805
|
-
const versionedWakeDue = def.kind === "wake" && def.definitionVersion !== undefined && !dryRun
|
|
806
|
-
&& (def.enabled || only) && ((js.pendingWake && !only) || only || (due && js.lastAttemptedMinute !== key));
|
|
807
|
-
if (versionedWakeDue) {
|
|
808
|
-
let error;
|
|
809
|
-
try { admitExecution(def, io, { createIntent: false }); } catch (caught) { error = caught; }
|
|
810
|
-
if (error) {
|
|
811
|
-
if (only || due) js.lastAttemptedMinute = key;
|
|
812
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), kind: "wake", launched: false, outcome: "blocked", error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" };
|
|
813
|
-
record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
|
|
814
|
-
continue;
|
|
815
|
-
}
|
|
816
|
-
throw scheduleError("migration-required", "captured wake unexpectedly passed its lifecycle integration gate");
|
|
817
|
-
}
|
|
818
688
|
if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
|
|
819
689
|
const dueNow = due && js.lastAttemptedMinute !== key;
|
|
820
690
|
const held = !!jobLockInfo(ws, id);
|
|
821
691
|
const canStart = held || liveLocks(wsAll) < bound;
|
|
822
692
|
const run = performWake(def, io, { startIfStopped: dueNow, canStart, reserve: () => held || acquireJobLock(ws, id, { scheduledFor: js.pendingWake.scheduledFor, wake: true, home: def.home }) });
|
|
823
|
-
if (run.
|
|
693
|
+
if (run.startedHarness) js.lastLaunchedAt = now.toISOString();
|
|
824
694
|
if (!existsSync(def.home)) releaseJobLock(ws, id);
|
|
825
695
|
js.lastRun = { ...(js.lastRun || {}), ...run, outcome: run.action, completedPending: run.action === "delivered", pendingSince: js.pendingWake.scheduledFor, startedAt: js.lastRun?.startedAt || now.toISOString() };
|
|
826
696
|
if (!run.pending) delete js.pendingWake;
|
|
@@ -832,12 +702,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
832
702
|
if (js.lastAttemptedMinute === key && !only) continue;
|
|
833
703
|
if (!due) continue;
|
|
834
704
|
if (dryRun) {
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
try { admitExecution(def, io, { createIntent: false }); } catch (error) { admission = error; }
|
|
838
|
-
}
|
|
839
|
-
const reason = js.attempt ? "an earlier launch has no recorded result; run oats schedule reconcile" : jobLockInfo(ws, id) && def.kind !== "wake" ? "still running" : liveLocks(wsAll) >= bound && def.kind !== "wake" ? "host busy" : admission ? `admission blocked (${admission.code || "error"}): ${admission.message}` : undefined;
|
|
840
|
-
record(id, reason ? (admission ? "blocked" : "skipped") : "due", reason ? { reason, ...(admission ? { errorCode: admission.code } : {}) } : {});
|
|
705
|
+
const reason = js.attempt ? "an earlier launch has no recorded result; run oats schedule reconcile" : jobLockInfo(ws, id) && def.kind !== "wake" ? "still running" : liveLocks(wsAll) >= bound && def.kind !== "wake" ? "host busy" : undefined;
|
|
706
|
+
record(id, reason ? "skipped" : "due", reason ? { reason } : {});
|
|
841
707
|
continue;
|
|
842
708
|
}
|
|
843
709
|
js.lastAttemptedMinute = key;
|
|
@@ -845,9 +711,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
845
711
|
let invalid;
|
|
846
712
|
try { validateAttemptExecution(js.attempt, jobLockInfo(ws, id)); } catch (error) { invalid = error; }
|
|
847
713
|
record(id, invalid ? "invalid" : "skipped", { reason: invalid ? `admitted attempt is invalid (${invalid.code || "error"}): ${invalid.message}` : "an earlier launch has no recorded result; run oats schedule reconcile", ...(invalid ? { errorCode: invalid.code } : {}) });
|
|
848
|
-
|
|
849
|
-
js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: invalid ? `invalid admitted attempt: ${invalid.message}` : "launch attempt without a recorded result", ...(invalid ? { errorCode: invalid.code } : {}), scheduledFor: js.attempt.scheduledFor };
|
|
850
|
-
}
|
|
714
|
+
js.lastRun = { ...(js.lastRun || {}), outcome: "unknown", error: invalid ? `invalid admitted attempt: ${invalid.message}` : "launch attempt without a recorded result", ...(invalid ? { errorCode: invalid.code } : {}), scheduledFor: js.attempt.scheduledFor };
|
|
851
715
|
continue;
|
|
852
716
|
}
|
|
853
717
|
if (def.kind === "wake") {
|
|
@@ -855,7 +719,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
855
719
|
// one does, and the slot is held (the job lock) until that home ends.
|
|
856
720
|
const holdsSlot = !!jobLockInfo(ws, id);
|
|
857
721
|
const run = performWake(def, io, { startIfStopped: true, canStart: holdsSlot || liveLocks(wsAll) < bound, reserve: () => holdsSlot || acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), wake: true, home: def.home }) });
|
|
858
|
-
if (run.
|
|
722
|
+
if (run.startedHarness) js.lastLaunchedAt = now.toISOString();
|
|
859
723
|
if (!existsSync(def.home)) releaseJobLock(ws, id);
|
|
860
724
|
if (run.pending) js.pendingWake = { scheduledFor: minute.toISOString() }; else delete js.pendingWake;
|
|
861
725
|
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action, ...(run.instance ? { instance: run.instance } : {}) };
|
|
@@ -864,26 +728,18 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
864
728
|
}
|
|
865
729
|
if (jobLockInfo(ws, id)) { record(id, "skipped", { reason: "still running" }); continue; }
|
|
866
730
|
if (liveLocks(wsAll) >= bound) { record(id, "skipped", { reason: "host busy" }); continue; }
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
record(id, "blocked", { error: error.message, errorCode: error.code || "E_SCHEDULE_ADMISSION" });
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
// Persist the admitted authority before even reserving the launch slot.
|
|
875
|
-
// A crash in the following gap leaves an inspectable unresolved attempt,
|
|
876
|
-
// never an ownerless lock whose intended software cannot be recovered.
|
|
877
|
-
js.attempt = { ...(execution ? { schemaVersion: SCHEDULE_ATTEMPT_VERSION } : {}), scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString(), ...(execution ? { execution } : {}) };
|
|
731
|
+
// Persist the attempt before even reserving the launch slot. A crash in
|
|
732
|
+
// the following gap leaves an inspectable unresolved attempt, never an
|
|
733
|
+
// ownerless lock.
|
|
734
|
+
js.attempt = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString() };
|
|
878
735
|
writeState(ws, st);
|
|
879
|
-
if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString()
|
|
736
|
+
if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString() })) {
|
|
880
737
|
delete js.attempt; writeState(ws, st); record(id, "skipped", { reason: "still running" }); continue;
|
|
881
738
|
}
|
|
882
739
|
js.lastLaunchedAt = now.toISOString();
|
|
883
740
|
writeState(ws, st);
|
|
884
741
|
let run;
|
|
885
|
-
|
|
886
|
-
try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : admitted, io); }
|
|
742
|
+
try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : def, io); }
|
|
887
743
|
catch (e) {
|
|
888
744
|
// spawnInstance compensates its own failures, but its rollback can be
|
|
889
745
|
// INCOMPLETE (a pane it could not stop, a quarantined home it kept):
|
|
@@ -893,13 +749,13 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
893
749
|
}
|
|
894
750
|
if (run.unconfirmed) {
|
|
895
751
|
// Side effects unconfirmed: keep the attempt and the slot; reconcile decides.
|
|
896
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...
|
|
752
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
|
|
897
753
|
record(id, "unknown", { error: run.error });
|
|
898
754
|
continue;
|
|
899
755
|
}
|
|
900
756
|
delete js.attempt;
|
|
901
757
|
const outcome = run.launched ? "launched" : "launch-failed";
|
|
902
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...
|
|
758
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome };
|
|
903
759
|
if (!run.launched || !run.home) releaseJobLock(ws, id);
|
|
904
760
|
record(id, outcome, { ...(run.error ? { error: run.error } : {}), ...(run.instance ? { instance: run.instance } : {}) });
|
|
905
761
|
}
|
|
@@ -972,27 +828,25 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
972
828
|
return withHostLock(() => {
|
|
973
829
|
const st = readState(ws);
|
|
974
830
|
const js = st.jobs[id] || (st.jobs[id] = {});
|
|
975
|
-
const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? {
|
|
831
|
+
const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? { scheduledFor: js.lastRun.scheduledFor, startedAt: js.lastRun.startedAt, ...(js.lastRun.execution !== undefined ? { execution: js.lastRun.execution } : {}) } : undefined);
|
|
976
832
|
if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
|
|
977
833
|
try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
|
|
978
|
-
catch (error) { throw scheduleError("E_SCHEDULE_INVALID",
|
|
979
|
-
const attemptKind =
|
|
834
|
+
catch (error) { throw scheduleError("E_SCHEDULE_INVALID", error.message); }
|
|
835
|
+
const attemptKind = def.kind;
|
|
980
836
|
let adopted, ambiguous;
|
|
981
837
|
if (attemptKind === "spawn" && attempt.scheduledFor) {
|
|
982
838
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(new Date(attempt.scheduledFor))}`;
|
|
983
|
-
|
|
839
|
+
// Only a home of THIS schedule's soul: instance names are deployment-wide
|
|
840
|
+
// (0.26.0), so another soul's instance can never be this run's — a name it
|
|
841
|
+
// holds made this run's spawn derive a suffixed name instead.
|
|
842
|
+
const homes = findHomesInScope(ws, `${def.agent}-${purpose}`).filter((h) => {
|
|
843
|
+
try { const agent = JSON.parse(readFileSync(join(h, "instance.json"), "utf8")).agent; return typeof agent !== "string" || agent === def.agent; } catch { return true; }
|
|
844
|
+
});
|
|
984
845
|
if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
|
|
985
846
|
else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
|
|
986
847
|
} else if (attemptKind === "command" || attemptKind === "operation") {
|
|
987
848
|
let named = [];
|
|
988
|
-
if (
|
|
989
|
-
// Only this admission's receipt may establish effects. Never borrow the
|
|
990
|
-
// previous run's name or consult current-config roster discovery.
|
|
991
|
-
if (receiptMatchesExecution(js.lastRun, attempt.execution) && typeof js.lastRun.home === "string" && isAbsolute(js.lastRun.home)) {
|
|
992
|
-
const home = resolve(js.lastRun.home), meta = readJson(join(home, "instance.json"), null);
|
|
993
|
-
if (meta?.instance === js.lastRun.instance && typeof meta.home === "string" && resolve(meta.home) === home) named = [{ instance: meta.instance, home }];
|
|
994
|
-
}
|
|
995
|
-
} else if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
|
|
849
|
+
if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
|
|
996
850
|
if (named.length === 1) adopted = named[0];
|
|
997
851
|
else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
|
|
998
852
|
else if (!clear) return { schedule: describe(ws, id, io), reconciled: "unknown", remedy: `the command's effects cannot be proven from a matching admitted receipt; check the roster and the host by hand, then run oats schedule reconcile ${id} --clear to record launch-failed and free the slot, or oats schedule remove --force ${id}` };
|
|
@@ -1002,10 +856,10 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
1002
856
|
delete js.attempt;
|
|
1003
857
|
if (adopted) {
|
|
1004
858
|
const seen = observeHome(adopted.home, io);
|
|
1005
|
-
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind,
|
|
1006
|
-
if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true
|
|
859
|
+
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
|
|
860
|
+
if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true }); } else releaseJobLock(ws, id);
|
|
1007
861
|
} else {
|
|
1008
|
-
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind,
|
|
862
|
+
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, launched: false, outcome: "launch-failed", error: clear ? "cleared by the operator after checking by hand" : "no instance found in the scope's roster for the unrecorded attempt" };
|
|
1009
863
|
releaseJobLock(ws, id);
|
|
1010
864
|
}
|
|
1011
865
|
writeState(ws, st);
|
|
@@ -1021,7 +875,7 @@ function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(
|
|
|
1021
875
|
* transcript: there is no reader behind this block. */
|
|
1022
876
|
function sessionProvenanceOf(r) {
|
|
1023
877
|
if (!r || typeof r !== "object") return null;
|
|
1024
|
-
const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
|
|
878
|
+
const delivery = r.startedHarness || r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
|
|
1025
879
|
return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
|
|
1026
880
|
}
|
|
1027
881
|
/** Read-time projection of one job's stored history: bounded to
|
|
@@ -1049,7 +903,7 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
1049
903
|
const js = st.jobs[id] || {};
|
|
1050
904
|
let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
|
|
1051
905
|
const lock = jobLockInfo(ws, id);
|
|
1052
|
-
const intent = js.attempt || (lock ? (js.lastRun?.execution ? {
|
|
906
|
+
const intent = js.attempt || (lock ? (js.lastRun?.execution !== undefined ? { execution: js.lastRun.execution } : {}) : undefined);
|
|
1053
907
|
const { history, recentRuns } = projectHistory(js);
|
|
1054
908
|
return { ...def, id, scope: ws, scheduleApi: 2, scheduleHistoryApi: SCHEDULE_HISTORY_API, executionStatus: scheduleExecutionStatus(def, intent, lock), nextRun, lastRun: js.lastRun ? { ...js.lastRun, runId: runIdOf(js.lastRun), session: sessionProvenanceOf(js.lastRun) } : null, history, recentRuns, running: !!lock, ...(js.attempt ? { attempt: js.attempt } : {}), ...(js.pendingWake ? { pendingWake: js.pendingWake } : {}) };
|
|
1055
909
|
}
|
|
@@ -1067,8 +921,6 @@ export function addSchedule(ws, spec, io) {
|
|
|
1067
921
|
return withScopeLock(ws, () => {
|
|
1068
922
|
const defs = readDefinitions(ws);
|
|
1069
923
|
if (defs.jobs[def.id]) throw scheduleError("E_SCHEDULE_EXISTS", `schedule ${def.id} already exists in ${ws}`);
|
|
1070
|
-
if (defs.version === 2 && def.definitionVersion === undefined) throw scheduleError("migration-required", "version 2 schedule files require an explicit recurrence policy for new definitions");
|
|
1071
|
-
if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
|
|
1072
924
|
const now = new Date().toISOString();
|
|
1073
925
|
defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
|
|
1074
926
|
writeDefinitions(ws, defs);
|
|
@@ -1084,8 +936,6 @@ export function updateSchedule(ws, id, spec, io) {
|
|
|
1084
936
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
1085
937
|
const defs = readDefinitions(ws);
|
|
1086
938
|
if (!defs.jobs[id]) throw scheduleError("E_SCHEDULE_UNKNOWN", `no schedule ${JSON.stringify(id)} in ${ws}`);
|
|
1087
|
-
if (defs.jobs[id].definitionVersion !== undefined && def.definitionVersion === undefined) throw scheduleError("migration-required", "a versioned schedule definition cannot be downgraded to legacy execution");
|
|
1088
|
-
if (def.definitionVersion === SCHEDULE_DEFINITION_VERSION) defs.version = 2;
|
|
1089
939
|
const identity = (d) => JSON.stringify([d.kind, d.agent, d.agentsRoot, d.repo, d.purpose, d.home, d.cwd, d.argv, d.operation, d.definitionVersion, d.recurrencePolicy, d.execution, d.preparation]);
|
|
1090
940
|
const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
|
|
1091
941
|
if (busy && identity(defs.jobs[id]) !== identity(def)) throw scheduleError("E_SCHEDULE_RUNNING", `schedule ${id} is running or has an unresolved attempt; its execution identity, kind, target and captured policy cannot change until it ends (cron, tz and enabled can)`);
|
|
@@ -1119,16 +969,10 @@ export function removeSchedule(ws, id, { force = false } = {}) {
|
|
|
1119
969
|
}));
|
|
1120
970
|
}
|
|
1121
971
|
/** Save the wake job for a freshly spawned instance (id wake-<instance>). */
|
|
1122
|
-
export function saveWakeForHome(ws, { instance, home, wake
|
|
972
|
+
export function saveWakeForHome(ws, { instance, home, wake }) {
|
|
1123
973
|
const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
|
|
1124
974
|
const spec = { id, enabled: wake.enabled === undefined ? true : wake.enabled, cron: wake.cron, tz: wake.tz, kind: "wake", home, message: wake.message };
|
|
1125
|
-
|
|
1126
|
-
if (executionBinding === undefined || responsibleHuman === undefined) throw scheduleError("needs-configuration", "captured wake creation needs both executionBinding and responsibleHuman");
|
|
1127
|
-
const binding = validateExecutionBinding(executionBinding), actual = homeExecutionBinding(home);
|
|
1128
|
-
if (binding.deployment !== actual.deployment || binding.resolution.id !== actual.resolution.id) throw scheduleError("E_SCHEDULE_INVALID", "spawn result executionBinding differs from the new home");
|
|
1129
|
-
Object.assign(spec, { definitionVersion: SCHEDULE_DEFINITION_VERSION, recurrencePolicy: "capture", responsibleHuman });
|
|
1130
|
-
}
|
|
1131
|
-
return addSchedule(ws, spec); // Same whole-file version/policy gate as manual add.
|
|
975
|
+
return addSchedule(ws, spec);
|
|
1132
976
|
}
|
|
1133
977
|
/** Retirement hook: wake definitions bound to this home are removed (nothing is stopped). */
|
|
1134
978
|
export function removeWakeForHome(ws, home) {
|