@awebai/oats 0.25.9 → 0.26.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 +576 -1714
- 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 +218 -47
- package/docs/capability-manifest.schema.json +13 -4
- package/docs/configuration.md +17 -5
- package/docs/conventions.md +16 -26
- 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 +537 -261
- package/docs/desktop-instance-start.md +1 -1
- package/docs/desktop.md +7 -13
- package/docs/execution-targets.md +16 -18
- package/docs/first-team.md +14 -17
- package/docs/implementation.md +28 -59
- 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 +29 -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 +75 -52
- 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/schedules.md +48 -126
- package/docs/soul.schema.json +11 -4
- package/docs/souls-and-instances.md +56 -43
- package/docs/workspaces.md +80 -58
- package/injects/instance-boundary.md +1 -1
- 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 +700 -4824
- package/lib/digest.mjs +12 -0
- package/lib/instance-inspect.mjs +396 -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/provider-binding.mjs +4 -2
- package/lib/provider-reasons.mjs +3 -68
- package/lib/resolve.mjs +204 -68
- package/lib/schedule.mjs +97 -272
- package/lib/servers.mjs +13 -13
- package/lib/{portable-shape.mjs → shape.mjs} +4 -3
- package/lib/tree-copy.mjs +44 -0
- package/lib/workspace.mjs +125 -20
- package/package-catalog.json +6 -6
- package/package.json +1 -1
- 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,17 +20,15 @@
|
|
|
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 { RESERVED_LAUNCH_ENV,
|
|
32
|
-
import { RECURRENCE_POLICIES, SCHEDULE_ATTEMPT_VERSION, SCHEDULE_DEFINITION_VERSION, admitExecutionTemplate, buildCommandExecutionTemplate, buildWakeExecutionTemplate, capturedDispatchAction, commandArgvWithExecutionBinding, executionContentIntegrity, validateExecutionBinding, validateExecutionCapsule, validateExecutionTemplate, validatePreparationInput } from "./schedule-capsule.mjs";
|
|
31
|
+
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
33
32
|
|
|
34
33
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
35
34
|
export const SCHEDULE_API = 2;
|
|
@@ -41,28 +40,24 @@ const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oat
|
|
|
41
40
|
const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
42
41
|
|
|
43
42
|
export function scheduleError(code, message, extra) { return Object.assign(new Error(message), { code, ...(extra || {}) }); }
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
43
|
+
/** describe's `executionStatus` (a released wire, kept as it was): a definition is
|
|
44
|
+
* `legacy` — the only kind that runs — or `invalid` (a captured one included,
|
|
45
|
+
* since 0.26), and so is its unresolved attempt. */
|
|
46
46
|
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
|
-
}
|
|
47
|
+
const versioned = CAPTURED_SCHEDULE_KEYS.some((key) => def?.[key] !== undefined);
|
|
48
|
+
const result = versioned ? { kind: "invalid", capture: "unknown", migrationRequired: true, reason: shapeError(def) } : { kind: "legacy", capture: "unknown", migrationRequired: true };
|
|
56
49
|
if (!intent) return result;
|
|
57
50
|
try {
|
|
58
|
-
|
|
59
|
-
return { ...result, intent:
|
|
60
|
-
: { kind: "legacy", capture: "unknown", migrationRequired: true } };
|
|
51
|
+
validateAttemptExecution(intent, lock);
|
|
52
|
+
return { ...result, intent: { kind: "legacy", capture: "unknown", migrationRequired: true } };
|
|
61
53
|
} catch (error) {
|
|
62
54
|
return { ...result, intent: { kind: "invalid", capture: "unknown", migrationRequired: true, reason: error.message, errorCode: error.code } };
|
|
63
55
|
}
|
|
64
56
|
}
|
|
65
57
|
|
|
58
|
+
/** The definition keys of a captured (versioned) schedule. That path was removed in 0.26. */
|
|
59
|
+
export const CAPTURED_SCHEDULE_KEYS = Object.freeze(["definitionVersion", "recurrencePolicy", "execution", "preparation"]);
|
|
60
|
+
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";
|
|
66
61
|
export function shapeError(def) {
|
|
67
62
|
if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
|
|
68
63
|
if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
|
|
@@ -71,54 +66,29 @@ export function shapeError(def) {
|
|
|
71
66
|
if (def.kind === "command" && (typeof def.cwd !== "string" || !Array.isArray(def.argv))) return "cwd and argv are required";
|
|
72
67
|
if (def.kind === "wake" && (typeof def.home !== "string" || typeof def.message !== "string")) return "home and message are required";
|
|
73
68
|
if (def.kind === "operation" && (typeof def.operation !== "string" || typeof def.home !== "string")) return "operation and home are required";
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (!["command", "wake"].includes(def.kind)) return "versioned execution currently supports command and captured wake definitions only";
|
|
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
|
-
}
|
|
69
|
+
// A stored captured (versioned) definition is never evaluated or run (removed in 0.26): it reads
|
|
70
|
+
// as invalid, migration required, on that job only.
|
|
71
|
+
if (CAPTURED_SCHEDULE_KEYS.some((key) => def[key] !== undefined)) return CAPTURED_SCHEDULE_REMOVED;
|
|
97
72
|
return null;
|
|
98
73
|
}
|
|
99
74
|
|
|
100
75
|
// ---------------------------------------------------------------- scope
|
|
101
76
|
|
|
102
|
-
/** The one schedule-owning scope for a directory: the
|
|
103
|
-
*
|
|
104
|
-
*
|
|
77
|
+
/** The one schedule-owning scope for a directory: the deployment, i.e. the
|
|
78
|
+
* directory holding the oats-local.yaml found walking up. No deployment in
|
|
79
|
+
* reach is E_LOCAL_MISSING; an ambient OATS_ROOT / PI_AGENTS_ROOT never
|
|
80
|
+
* chooses the scope (lead decision c3-5). */
|
|
105
81
|
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];
|
|
82
|
+
let found;
|
|
83
|
+
try { found = loadLocal(dir); }
|
|
84
|
+
catch (e) {
|
|
85
|
+
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) } });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
return dirname(found.path);
|
|
121
89
|
}
|
|
90
|
+
/** The scope's one agents root: <deployment>/agents. */
|
|
91
|
+
export function scopeRoots(scope) { return [resolve(scope, "agents")]; }
|
|
122
92
|
|
|
123
93
|
// ------------------------------------------------------------- files
|
|
124
94
|
|
|
@@ -321,6 +291,10 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
321
291
|
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
292
|
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
293
|
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; }
|
|
294
|
+
// A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
|
|
295
|
+
// MAX_INSTANCE_NAME characters, so refuse here what would fail at every firing.
|
|
296
|
+
{ const stem = out.purpose ?? id, derived = `${out.agent}-${stem}-${"0".repeat(12)}`;
|
|
297
|
+
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
298
|
if (typeof def.task !== "string" || !def.task.trim()) throw scheduleError("E_SCHEDULE_INVALID", "task: non-empty text", { field: "task" });
|
|
325
299
|
out.task = def.task;
|
|
326
300
|
if (def.runtime !== undefined) { if (!RUNTIMES.has(def.runtime)) throw scheduleError("E_SCHEDULE_INVALID", "runtime: pi, claude or codex", { field: "runtime" }); out.runtime = def.runtime; }
|
|
@@ -337,6 +311,8 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
337
311
|
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
312
|
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
313
|
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" });
|
|
314
|
+
const selector = def.argv.slice(1, def.argv.includes("--") ? def.argv.indexOf("--") : undefined).find((a) => /^--(deployment|resolution|artifact-set)(=|$)/.test(a));
|
|
315
|
+
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
316
|
out.cwd = resolve(def.cwd); out.argv = [...def.argv];
|
|
341
317
|
} else if (def.kind === "wake") {
|
|
342
318
|
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 +326,19 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
350
326
|
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
327
|
out.operation = def.operation; out.home = resolve(def.home);
|
|
352
328
|
} 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
|
-
}
|
|
329
|
+
// Captured (versioned) schedules were removed in 0.26 (lead decisions on (e), D4).
|
|
330
|
+
const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => def[key] !== undefined);
|
|
331
|
+
if (captured.length) throw scheduleError("E_SCHEDULE_INVALID", `${captured.join(", ")}: ${CAPTURED_SCHEDULE_REMOVED}`, { field: captured[0] });
|
|
393
332
|
return out;
|
|
394
333
|
}
|
|
395
334
|
|
|
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. */
|
|
335
|
+
/** The soul a spawn job launches, under the deployment's one agents root
|
|
336
|
+
* (a definition's agentsRoot, when given, must be that root). `repo` is the
|
|
337
|
+
* work repository and never selects a soul. */
|
|
401
338
|
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; } }
|
|
339
|
+
const [root] = scopeRoots(ws);
|
|
340
|
+
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" });
|
|
341
|
+
const agent = findAgent(root, def.agent);
|
|
408
342
|
if (!agent) throw scheduleError("E_SCHEDULE_INVALID", `agent: no soul named ${JSON.stringify(def.agent)} under ${root}`, { field: "agent" });
|
|
409
343
|
return { root, agent };
|
|
410
344
|
}
|
|
@@ -452,7 +386,7 @@ function startupInProgress(home) {
|
|
|
452
386
|
}
|
|
453
387
|
|
|
454
388
|
/** All homes for an instance name across the scope's roster (every known
|
|
455
|
-
* agents root
|
|
389
|
+
* agents root). */
|
|
456
390
|
export function findHomesInScope(ws, instance) {
|
|
457
391
|
const homes = new Set();
|
|
458
392
|
for (const root of scopeRoots(ws)) {
|
|
@@ -465,8 +399,6 @@ export function findHomesInScope(ws, instance) {
|
|
|
465
399
|
const h = join(root, e.name, "instances", instance);
|
|
466
400
|
if (existsSync(join(h, "instance.json"))) homes.add(resolve(h));
|
|
467
401
|
}
|
|
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
402
|
}
|
|
471
403
|
return [...homes];
|
|
472
404
|
}
|
|
@@ -490,22 +422,12 @@ function childEnv() {
|
|
|
490
422
|
]) delete env[key];
|
|
491
423
|
return env;
|
|
492
424
|
}
|
|
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;
|
|
425
|
+
/** The deployment a spawn job runs in (module contract §5, decision 7): the
|
|
426
|
+
* scope's oats-local.yaml. An unreadable one is reported (E_WORKSPACE_SCHEMA)
|
|
427
|
+
* by the spawn, never answered some other way. */
|
|
428
|
+
export function workspaceSpawnContext(ws) {
|
|
429
|
+
try { const found = loadLocal(ws); return { kind: "local", local: found.path, dir: dirname(found.path) }; }
|
|
430
|
+
catch (e) { return { kind: "local", local: null, dir: ws, error: { code: e.code || "E_WORKSPACE_SCHEMA", message: e.message } }; }
|
|
509
431
|
}
|
|
510
432
|
/** A workspace spawn materializes EXACTLY like `oats spawn` — prepareInstance →
|
|
511
433
|
* ensureWorkspaceSoul → spawnInstanceAsync (resolve over the remotes, copy every
|
|
@@ -551,20 +473,16 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
|
551
473
|
return { instance: res.instance, home: res.home, launched: res.launched, materialized: "workspace" };
|
|
552
474
|
}
|
|
553
475
|
/** 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. */
|
|
476
|
+
* launcher for tests. Otherwise the kernel's own `oats spawn` runs in the
|
|
477
|
+
* deployment (workspace materialization: `.oats/modules`, module skills and
|
|
478
|
+
* injects). */
|
|
558
479
|
function launchSpawn(ws, def, minute, io) {
|
|
559
480
|
const { root, agent } = resolveScheduledAgent(ws, def);
|
|
560
481
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
|
|
561
482
|
const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, runtime: def.runtime, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
|
|
562
483
|
let r;
|
|
563
484
|
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
|
-
}
|
|
485
|
+
else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
|
|
568
486
|
const run = { instance: r.instance, home: r.home, launched: !!r.instance, kind: "spawn", ...(r.materialized ? { materialized: r.materialized } : {}) };
|
|
569
487
|
if (def.wake && r.home) {
|
|
570
488
|
try { run.wakeSchedule = saveWakeForHome(ws, { instance: r.instance, home: r.home, wake: def.wake }); }
|
|
@@ -629,70 +547,17 @@ function launchCommand(ws, def, io) {
|
|
|
629
547
|
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
548
|
}
|
|
631
549
|
|
|
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
|
-
}
|
|
550
|
+
/** A captured attempt or job lock left by 0.24–0.25 (the captured path, removed in
|
|
551
|
+
* 0.26) is never run, adopted or released here: only removing its job clears it. */
|
|
552
|
+
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
553
|
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;
|
|
554
|
+
if (attempt?.execution !== undefined || attempt?.schemaVersion !== undefined) throw scheduleError("migration-required", CAPTURED_ATTEMPT_REMOVED);
|
|
555
|
+
if (lock?.executionId !== undefined) throw scheduleError("E_SCHEDULE_INVALID", `captured job lock: ${CAPTURED_ATTEMPT_REMOVED}`);
|
|
652
556
|
}
|
|
653
557
|
function validateActiveExecution(js, lock) {
|
|
654
558
|
if (lock?.unreadable) throw scheduleError("E_SCHEDULE_INVALID", "job lock custody is unreadable");
|
|
655
559
|
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;
|
|
560
|
+
if (lock && (js.lastRun?.execution !== undefined || lock.executionId !== undefined)) validateAttemptExecution({ execution: js.lastRun?.execution }, lock);
|
|
696
561
|
}
|
|
697
562
|
/** One wake. `startIfStopped` false between due minutes (a harness that
|
|
698
563
|
* keeps exiting is not restarted every minute); `canStart` false when the
|
|
@@ -743,9 +608,12 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
|
|
|
743
608
|
* marker) or its home is gone: a persistent home outliving its process
|
|
744
609
|
* does not keep a slot. */
|
|
745
610
|
function observePrevious(ws, id, def, js, io, now, { mutate }) {
|
|
611
|
+
// An invalid definition (a stored captured one included) is reported on its
|
|
612
|
+
// job by the tick and never observed.
|
|
613
|
+
if (shapeError(def)) return;
|
|
746
614
|
const lock = jobLockInfo(ws, id);
|
|
747
615
|
validateActiveExecution(js, lock); // No observation may erase invalid custody.
|
|
748
|
-
if (js.attempt
|
|
616
|
+
if (js.attempt) return; // Unconfirmed effects need reconcile.
|
|
749
617
|
const lr = js.lastRun;
|
|
750
618
|
const home = def.kind === "wake" ? (lock?.home || lr?.home || def.home) : (lock?.home || lr?.home);
|
|
751
619
|
const tracked = def.kind === "wake" ? !!lock : !!home && ["launched", "active", "starting", "stopped", "unknown"].includes(lr?.outcome);
|
|
@@ -792,29 +660,12 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
792
660
|
for (const id of order) {
|
|
793
661
|
const def = defs.jobs[id];
|
|
794
662
|
const js = stateOf(id);
|
|
795
|
-
validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
|
|
796
663
|
let due, cronError;
|
|
797
664
|
const shape = shapeError(def);
|
|
665
|
+
if (!shape) validateActiveExecution(js, jobLockInfo(ws, id)); // Also covers host pre-observed candidates.
|
|
798
666
|
if (shape) { cronError = shape; due = false; }
|
|
799
667
|
else { try { due = only ? true : dueAt(def, minute); } catch (e) { cronError = e.message; due = false; } }
|
|
800
668
|
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
669
|
if (def.kind === "wake" && js.pendingWake && def.enabled && !dryRun && !only) {
|
|
819
670
|
const dueNow = due && js.lastAttemptedMinute !== key;
|
|
820
671
|
const held = !!jobLockInfo(ws, id);
|
|
@@ -832,12 +683,8 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
832
683
|
if (js.lastAttemptedMinute === key && !only) continue;
|
|
833
684
|
if (!due) continue;
|
|
834
685
|
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 } : {}) } : {});
|
|
686
|
+
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;
|
|
687
|
+
record(id, reason ? "skipped" : "due", reason ? { reason } : {});
|
|
841
688
|
continue;
|
|
842
689
|
}
|
|
843
690
|
js.lastAttemptedMinute = key;
|
|
@@ -845,9 +692,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
845
692
|
let invalid;
|
|
846
693
|
try { validateAttemptExecution(js.attempt, jobLockInfo(ws, id)); } catch (error) { invalid = error; }
|
|
847
694
|
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
|
-
}
|
|
695
|
+
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
696
|
continue;
|
|
852
697
|
}
|
|
853
698
|
if (def.kind === "wake") {
|
|
@@ -864,26 +709,18 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
864
709
|
}
|
|
865
710
|
if (jobLockInfo(ws, id)) { record(id, "skipped", { reason: "still running" }); continue; }
|
|
866
711
|
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 } : {}) };
|
|
712
|
+
// Persist the attempt before even reserving the launch slot. A crash in
|
|
713
|
+
// the following gap leaves an inspectable unresolved attempt, never an
|
|
714
|
+
// ownerless lock.
|
|
715
|
+
js.attempt = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), wallClock: new Date().toISOString() };
|
|
878
716
|
writeState(ws, st);
|
|
879
|
-
if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString()
|
|
717
|
+
if (!acquireJobLock(ws, id, { scheduledFor: minute.toISOString() })) {
|
|
880
718
|
delete js.attempt; writeState(ws, st); record(id, "skipped", { reason: "still running" }); continue;
|
|
881
719
|
}
|
|
882
720
|
js.lastLaunchedAt = now.toISOString();
|
|
883
721
|
writeState(ws, st);
|
|
884
722
|
let run;
|
|
885
|
-
|
|
886
|
-
try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : admitted, io); }
|
|
723
|
+
try { run = def.kind === "spawn" ? launchSpawn(ws, def, minute, io) : launchCommand(ws, def.kind === "operation" ? operationAsCommand(def) : def, io); }
|
|
887
724
|
catch (e) {
|
|
888
725
|
// spawnInstance compensates its own failures, but its rollback can be
|
|
889
726
|
// INCOMPLETE (a pane it could not stop, a quarantined home it kept):
|
|
@@ -893,13 +730,13 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
|
|
|
893
730
|
}
|
|
894
731
|
if (run.unconfirmed) {
|
|
895
732
|
// Side effects unconfirmed: keep the attempt and the slot; reconcile decides.
|
|
896
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...
|
|
733
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome: "unknown" };
|
|
897
734
|
record(id, "unknown", { error: run.error });
|
|
898
735
|
continue;
|
|
899
736
|
}
|
|
900
737
|
delete js.attempt;
|
|
901
738
|
const outcome = run.launched ? "launched" : "launch-failed";
|
|
902
|
-
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...
|
|
739
|
+
js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), ...run, outcome };
|
|
903
740
|
if (!run.launched || !run.home) releaseJobLock(ws, id);
|
|
904
741
|
record(id, outcome, { ...(run.error ? { error: run.error } : {}), ...(run.instance ? { instance: run.instance } : {}) });
|
|
905
742
|
}
|
|
@@ -972,27 +809,25 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
972
809
|
return withHostLock(() => {
|
|
973
810
|
const st = readState(ws);
|
|
974
811
|
const js = st.jobs[id] || (st.jobs[id] = {});
|
|
975
|
-
const attempt = js.attempt || (js.lastRun?.outcome === "unknown" ? {
|
|
812
|
+
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
813
|
if (!attempt) return { schedule: describe(ws, id, io), reconciled: "nothing to reconcile" };
|
|
977
814
|
try { validateAttemptExecution(attempt, jobLockInfo(ws, id)); }
|
|
978
|
-
catch (error) { throw scheduleError("E_SCHEDULE_INVALID",
|
|
979
|
-
const attemptKind =
|
|
815
|
+
catch (error) { throw scheduleError("E_SCHEDULE_INVALID", error.message); }
|
|
816
|
+
const attemptKind = def.kind;
|
|
980
817
|
let adopted, ambiguous;
|
|
981
818
|
if (attemptKind === "spawn" && attempt.scheduledFor) {
|
|
982
819
|
const purpose = `${def.purpose || def.id}-${purposeSuffix(new Date(attempt.scheduledFor))}`;
|
|
983
|
-
|
|
820
|
+
// Only a home of THIS schedule's soul: instance names are deployment-wide
|
|
821
|
+
// (0.26.0), so another soul's instance can never be this run's — a name it
|
|
822
|
+
// holds made this run's spawn derive a suffixed name instead.
|
|
823
|
+
const homes = findHomesInScope(ws, `${def.agent}-${purpose}`).filter((h) => {
|
|
824
|
+
try { const agent = JSON.parse(readFileSync(join(h, "instance.json"), "utf8")).agent; return typeof agent !== "string" || agent === def.agent; } catch { return true; }
|
|
825
|
+
});
|
|
984
826
|
if (homes.length === 1) adopted = { instance: `${def.agent}-${purpose}`, home: homes[0] };
|
|
985
827
|
else if (homes.length > 1) ambiguous = `${homes.length} homes named ${def.agent}-${purpose}: ${homes.join(", ")}`;
|
|
986
828
|
} else if (attemptKind === "command" || attemptKind === "operation") {
|
|
987
829
|
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 }));
|
|
830
|
+
if (js.lastRun?.instance) named = findHomesInScope(ws, js.lastRun.instance).map((home) => ({ instance: js.lastRun.instance, home }));
|
|
996
831
|
if (named.length === 1) adopted = named[0];
|
|
997
832
|
else if (named.length > 1) ambiguous = `${named.length} homes named ${js.lastRun.instance}: ${named.map((c) => c.home).join(", ")}`;
|
|
998
833
|
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 +837,10 @@ export function reconcile(ws, id, { io, now = new Date(), clear = false } = {})
|
|
|
1002
837
|
delete js.attempt;
|
|
1003
838
|
if (adopted) {
|
|
1004
839
|
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
|
|
840
|
+
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind, launched: true, ...adopted, outcome: seen.outcome, ...(seen.error ? { error: seen.error } : {}) };
|
|
841
|
+
if (seen.outcome !== "ended") { if (!jobLockInfo(ws, id)) acquireJobLock(ws, id, { scheduledFor: attempt.scheduledFor, reconciled: true }); } else releaseJobLock(ws, id);
|
|
1007
842
|
} else {
|
|
1008
|
-
js.lastRun = { scheduledFor: attempt.scheduledFor, startedAt: js.lastRun?.startedAt || attempt.startedAt, kind: attemptKind,
|
|
843
|
+
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
844
|
releaseJobLock(ws, id);
|
|
1010
845
|
}
|
|
1011
846
|
writeState(ws, st);
|
|
@@ -1049,7 +884,7 @@ export function describe(ws, id, io, { defs, st, now = new Date() } = {}) {
|
|
|
1049
884
|
const js = st.jobs[id] || {};
|
|
1050
885
|
let nextRun = null; try { nextRun = def.enabled ? nextRunAfter(def, now) : null; } catch { nextRun = null; }
|
|
1051
886
|
const lock = jobLockInfo(ws, id);
|
|
1052
|
-
const intent = js.attempt || (lock ? (js.lastRun?.execution ? {
|
|
887
|
+
const intent = js.attempt || (lock ? (js.lastRun?.execution !== undefined ? { execution: js.lastRun.execution } : {}) : undefined);
|
|
1053
888
|
const { history, recentRuns } = projectHistory(js);
|
|
1054
889
|
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
890
|
}
|
|
@@ -1067,8 +902,6 @@ export function addSchedule(ws, spec, io) {
|
|
|
1067
902
|
return withScopeLock(ws, () => {
|
|
1068
903
|
const defs = readDefinitions(ws);
|
|
1069
904
|
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
905
|
const now = new Date().toISOString();
|
|
1073
906
|
defs.jobs[def.id] = { ...def, createdAt: now, updatedAt: now };
|
|
1074
907
|
writeDefinitions(ws, defs);
|
|
@@ -1084,8 +917,6 @@ export function updateSchedule(ws, id, spec, io) {
|
|
|
1084
917
|
return withHostLock(() => withScopeLock(ws, () => {
|
|
1085
918
|
const defs = readDefinitions(ws);
|
|
1086
919
|
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
920
|
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
921
|
const busy = !!jobLockInfo(ws, id) || !!readState(ws).jobs[id]?.attempt;
|
|
1091
922
|
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 +950,10 @@ export function removeSchedule(ws, id, { force = false } = {}) {
|
|
|
1119
950
|
}));
|
|
1120
951
|
}
|
|
1121
952
|
/** Save the wake job for a freshly spawned instance (id wake-<instance>). */
|
|
1122
|
-
export function saveWakeForHome(ws, { instance, home, wake
|
|
953
|
+
export function saveWakeForHome(ws, { instance, home, wake }) {
|
|
1123
954
|
const id = `wake-${instance}`.slice(0, 40).replace(/-+$/, "");
|
|
1124
955
|
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.
|
|
956
|
+
return addSchedule(ws, spec);
|
|
1132
957
|
}
|
|
1133
958
|
/** Retirement hook: wake definitions bound to this home are removed (nothing is stopped). */
|
|
1134
959
|
export function removeWakeForHome(ws, home) {
|