@loopingai/core 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@loopingai/core/job` — a long job a Durable Object owns through its alarm.
3
+ *
4
+ * **The sibling of `@loopingai/core/alarm`, and the pairing is the point.**
5
+ * `WakeMap` owns *when* an object wakes; this owns *what a job owes on waking*.
6
+ * Neither depends on the other's reason for existing, and both are useful to a
7
+ * plain `DurableObject` rather than only to a `LoopingAgent` — which is why they
8
+ * are subpaths and not part of the agent machinery.
9
+ *
10
+ * **Mechanism only.** Nothing here knows what a job *does*: no command, no
11
+ * container, no filesystem, no vendor library. A consumer supplies the handle
12
+ * and the meaning; this supplies the four rules that are wrong in the same way
13
+ * every time — arming before the work starts, one job at a time, a drain that
14
+ * can outlive its job, and a job nobody is draining. See {@link JobLifecycle}.
15
+ *
16
+ * Deliberately **not** called `task`. Core already has a `Task` — the A2A one,
17
+ * with its own lifecycle, its own guarded writes and its own table — and two
18
+ * unrelated meanings in one namespace is a cost paid forever by every reader.
19
+ */
20
+ export { isRearmable, isRunning, type DoneJob, type FailedJob, type IdleJob, type JobState, type RunningJob, type SkippedJob } from "./state.js";
21
+ export { JobLifecycle, type JobContext, type JobHandle, type JobLifecycleOptions, type JobResult } from "./lifecycle.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@loopingai/core/job` — a long job a Durable Object owns through its alarm.
3
+ *
4
+ * **The sibling of `@loopingai/core/alarm`, and the pairing is the point.**
5
+ * `WakeMap` owns *when* an object wakes; this owns *what a job owes on waking*.
6
+ * Neither depends on the other's reason for existing, and both are useful to a
7
+ * plain `DurableObject` rather than only to a `LoopingAgent` — which is why they
8
+ * are subpaths and not part of the agent machinery.
9
+ *
10
+ * **Mechanism only.** Nothing here knows what a job *does*: no command, no
11
+ * container, no filesystem, no vendor library. A consumer supplies the handle
12
+ * and the meaning; this supplies the four rules that are wrong in the same way
13
+ * every time — arming before the work starts, one job at a time, a drain that
14
+ * can outlive its job, and a job nobody is draining. See {@link JobLifecycle}.
15
+ *
16
+ * Deliberately **not** called `task`. Core already has a `Task` — the A2A one,
17
+ * with its own lifecycle, its own guarded writes and its own table — and two
18
+ * unrelated meanings in one namespace is a cost paid forever by every reader.
19
+ */
20
+ export { isRearmable, isRunning } from "./state.js";
21
+ export { JobLifecycle } from "./lifecycle.js";
@@ -0,0 +1,176 @@
1
+ import type { WakeMap } from "../alarm/index.js";
2
+ import { type JobState, type RunningJob } from "./state.js";
3
+ /**
4
+ * The choreography around a long job a Durable Object owns through its alarm.
5
+ *
6
+ * The job itself — what command, where, and what its output means — belongs to
7
+ * the owner. What lives here is the part that is the same every time and is
8
+ * wrong in the same four ways every time:
9
+ *
10
+ * 1. **Arming writes `running` before anything runs.** The alarm has not fired
11
+ * yet, and a `done` record in that window lets a gated caller through against
12
+ * a workspace that is not ready. Writing `running` first also makes arming
13
+ * self-limiting: the next call sees it and stops.
14
+ * 2. **One job at a time**, guarded by a read that goes *through* the staleness
15
+ * bound — so a `running` record left by a dead isolate resolves rather than
16
+ * blocking every retry forever.
17
+ * 3. **A drain can outlive the job it watched.** `ctx.waitUntil` keeps running
18
+ * after the RPC returns, and a late drain writing its verdict over a record
19
+ * describing a *live* job is silent corruption. {@link generation} is the
20
+ * marker that makes it harmless.
21
+ * 4. **Nobody may be draining at all.** A watch intent re-attaches to a job
22
+ * whose isolate went away mid-flight.
23
+ *
24
+ * What is deliberately *not* here is the drain loop. Two real consumers want
25
+ * different ones — an install runs to completion under `waitUntil` and writes a
26
+ * single verdict; a coding-agent run is drained in bounded windows and reports
27
+ * partial progress between them. They share the four rules above and nothing
28
+ * below them, so the loop stays with the owner.
29
+ *
30
+ * ## Storage keys
31
+ *
32
+ * Derived from {@link JobLifecycleOptions.id} so one object can own several
33
+ * jobs. For `id: "install"` they come out as `install`, `install:armed`,
34
+ * `install:last-armed`, `install:context`, and the wake intents `install-run`
35
+ * and `install-watch` — the exact keys the predecessor wrote by hand, which is
36
+ * why adopting this needs no storage migration.
37
+ */
38
+ /** What a job's result looks like to the lifecycle. Deliberately minimal. */
39
+ export interface JobResult {
40
+ exitCode: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ }
44
+ /** A running command, reduced to what the lifecycle needs of it. */
45
+ export interface JobHandle {
46
+ result(): Promise<JobResult>;
47
+ [Symbol.dispose](): void;
48
+ }
49
+ /**
50
+ * The per-job record naming what is running and *which* run it is.
51
+ *
52
+ * `startedAt` is the generation marker, so it is the one required field: a drain
53
+ * compares the stamp it captured against the stamp on disk, and a mismatch means
54
+ * it has been superseded and has nothing useful left to say.
55
+ */
56
+ export interface JobContext {
57
+ startedAt: number;
58
+ }
59
+ export interface JobLifecycleOptions {
60
+ /** Namespaces every key and intent. Also the state record's own key. */
61
+ id: string;
62
+ storage: DurableObjectStorage;
63
+ wake: WakeMap;
64
+ /**
65
+ * How long a `running` record may stand before it is presumed dead.
66
+ *
67
+ * Measured from `startedAt` and compared against the job's own timeout plus
68
+ * this, never against this alone — the point is to outlast a job that is
69
+ * merely slow, and only then to declare one that is gone.
70
+ */
71
+ staleMs?: number;
72
+ /** How often the watch intent re-checks a job nobody is draining. */
73
+ watchMs?: number;
74
+ /**
75
+ * The floor between two arming attempts.
76
+ *
77
+ * Without it a job that cannot start re-arms on every call into the object.
78
+ */
79
+ armCooldownMs?: number;
80
+ }
81
+ export declare class JobLifecycle<TExtra extends object = Record<never, never>, TContext extends JobContext = JobContext> {
82
+ #private;
83
+ /** `install` — the state record. */
84
+ readonly stateKey: string;
85
+ /** `install:armed` — the stamp the arming path wrote, for the alarm to match. */
86
+ readonly armedKey: string;
87
+ /** `install:last-armed` — the cooldown floor. */
88
+ readonly lastArmedKey: string;
89
+ /** `install:context` — where the generation marker lives. */
90
+ readonly contextKey: string;
91
+ /** `install-run` — the intent that *runs* a job. */
92
+ readonly runIntent: string;
93
+ /** `install-watch` — the intent that re-attaches to one nobody is draining. */
94
+ readonly watchIntent: string;
95
+ constructor(options: JobLifecycleOptions);
96
+ /** The raw record, with no staleness repair. `idle` when nothing is written. */
97
+ read(): Promise<JobState<TExtra>>;
98
+ write(state: JobState<TExtra>): Promise<void>;
99
+ context(): Promise<TContext | undefined>;
100
+ /**
101
+ * Record which run this is, **before** spawning.
102
+ *
103
+ * The order is the whole point: a drain captures `startedAt` after the spawn,
104
+ * so a context written afterwards would let two runs share a generation.
105
+ */
106
+ putContext(context: TContext): Promise<void>;
107
+ /**
108
+ * Hand a cold job to the alarm, if one is not already pending.
109
+ *
110
+ * Returns the stamp it armed with, or `undefined` when it declined — the
111
+ * caller needs the stamp because it is what the alarm must present to
112
+ * {@link claim} to get past the single-flight guard.
113
+ *
114
+ * An arming caller must **not** own the run. The predecessor handed one to
115
+ * `ctx.waitUntil` from a gate poll that returned in milliseconds, and the
116
+ * drain was disposed underneath it mid-command. An alarm invocation belongs to
117
+ * the object rather than to any request, so nothing it awaits can be cut short.
118
+ */
119
+ arm(placeholder: Omit<RunningJob<TExtra>, "state" | "startedAt">): Promise<number | undefined>;
120
+ /** The stamp {@link arm} wrote, so the alarm can recognise its own placeholder. */
121
+ armedAt(): Promise<number | undefined>;
122
+ clearArmed(): Promise<void>;
123
+ /**
124
+ * Decide whether a new run may start.
125
+ *
126
+ * `takeOverArmedAt` is the one exemption and it is narrow on purpose. The
127
+ * alarm's placeholder *is* a `running` record for a job that has not started,
128
+ * so the alarm has to pass its own guard — and only its own. Matching the
129
+ * exact stamp it wrote is what stops this becoming "take over any running
130
+ * job", which is the displacement bug the guard exists to prevent: three
131
+ * callers spawning under one exec id in fifty seconds, each displacing the
132
+ * last, every displaced drain still attached and still writing verdicts.
133
+ *
134
+ * Applies the staleness bound **itself**, rather than trusting the caller to
135
+ * have repaired the record first. An earlier draft took an
136
+ * "already-repaired" state and said so in prose, which enforced nothing: the
137
+ * repaired and raw types are identical, so a caller passing a raw read got a
138
+ * `running` record that could never be claimed and a job wedged forever.
139
+ * `timeoutMs` is the job's own budget; see {@link isStale}.
140
+ */
141
+ claim(state: JobState<TExtra>, timeoutMs: number, takeOverArmedAt?: number): {
142
+ ok: true;
143
+ } | {
144
+ ok: false;
145
+ current: RunningJob<TExtra>;
146
+ };
147
+ /**
148
+ * Whether a `running` record has stood long enough to be presumed dead.
149
+ *
150
+ * `timeoutMs` is the job's own budget; the bound is that plus `staleMs`, so a
151
+ * job that is merely slow is never declared gone.
152
+ */
153
+ isStale(state: RunningJob<TExtra>, timeoutMs: number, now?: number): boolean;
154
+ /** Arm the watchdog that re-attaches to a job nobody is draining. */
155
+ armWatch(now?: number): Promise<void>;
156
+ /**
157
+ * Disarm the watchdog.
158
+ *
159
+ * Never call this from a superseded drain: the watchdog belongs to whichever
160
+ * run owns the record *now*, and clearing it there disarms the one recovery
161
+ * path the live run has.
162
+ */
163
+ clearWatch(): Promise<void>;
164
+ /**
165
+ * A predicate a drain calls before every write, to ask whether it still owns
166
+ * the record.
167
+ *
168
+ * Captures the stamp once, at drain start, and compares it against disk each
169
+ * time. The closure also latches, so a drain can ask afterwards whether it was
170
+ * superseded — which is what decides if it may touch the watchdog.
171
+ */
172
+ generation(startedAt: number): {
173
+ stillMine: () => Promise<boolean>;
174
+ superseded: () => boolean;
175
+ };
176
+ }
@@ -0,0 +1,230 @@
1
+ import { isRearmable } from "./state.js";
2
+ /**
3
+ * `WakeMap`'s own storage row, spelled here rather than imported.
4
+ *
5
+ * Importing `WAKE_KEY` would be a *value* import from `../alarm`, and this
6
+ * module is careful to reach that package only for types — a runtime edge would
7
+ * pull the whole alarm module into any bundle that imports `/job`. So the string
8
+ * is duplicated, and `lifecycle.spec.ts` asserts it still equals `WAKE_KEY`;
9
+ * specs never ship, so the check costs nothing at runtime and fails loudly if
10
+ * the two ever drift.
11
+ */
12
+ const WAKE_MAP_KEY = "wake";
13
+ const DEFAULT_STALE_MS = 5 * 60_000;
14
+ const DEFAULT_WATCH_MS = 60_000;
15
+ const DEFAULT_ARM_COOLDOWN_MS = 5 * 60_000;
16
+ export class JobLifecycle {
17
+ #o;
18
+ /** `install` — the state record. */
19
+ stateKey;
20
+ /** `install:armed` — the stamp the arming path wrote, for the alarm to match. */
21
+ armedKey;
22
+ /** `install:last-armed` — the cooldown floor. */
23
+ lastArmedKey;
24
+ /** `install:context` — where the generation marker lives. */
25
+ contextKey;
26
+ /** `install-run` — the intent that *runs* a job. */
27
+ runIntent;
28
+ /** `install-watch` — the intent that re-attaches to one nobody is draining. */
29
+ watchIntent;
30
+ constructor(options) {
31
+ /**
32
+ * An id is a storage key, so a bad one is not a bad name — it is a write
33
+ * landing on somebody else's row.
34
+ *
35
+ * `"wake"` is the one that matters and the reason this guard exists: it is
36
+ * `WakeMap`'s single row, so a job with that id would overwrite the whole
37
+ * intent map on its first state write, and the `wake.set()` immediately
38
+ * after would then read job fields as intents. Every pending wake-up on the
39
+ * object — not just this job's — silently stops happening.
40
+ *
41
+ * Empty is rejected for the same reason one level down: it yields the
42
+ * intents `-run` and `-watch`, which two differently-broken callers would
43
+ * share.
44
+ */
45
+ if (!options.id)
46
+ throw new Error("a job id must be a non-empty string");
47
+ if (options.id === WAKE_MAP_KEY) {
48
+ throw new Error(`"${WAKE_MAP_KEY}" is reserved: it is WakeMap's storage row, and a job ` +
49
+ `with that id would overwrite every pending intent on this object`);
50
+ }
51
+ this.#o = {
52
+ ...options,
53
+ staleMs: options.staleMs ?? DEFAULT_STALE_MS,
54
+ watchMs: options.watchMs ?? DEFAULT_WATCH_MS,
55
+ armCooldownMs: options.armCooldownMs ?? DEFAULT_ARM_COOLDOWN_MS
56
+ };
57
+ this.stateKey = options.id;
58
+ this.armedKey = `${options.id}:armed`;
59
+ this.lastArmedKey = `${options.id}:last-armed`;
60
+ this.contextKey = `${options.id}:context`;
61
+ this.runIntent = `${options.id}-run`;
62
+ this.watchIntent = `${options.id}-watch`;
63
+ }
64
+ // --- the record ------------------------------------------------------------
65
+ /** The raw record, with no staleness repair. `idle` when nothing is written. */
66
+ async read() {
67
+ return ((await this.#o.storage.get(this.stateKey)) ??
68
+ { state: "idle" });
69
+ }
70
+ async write(state) {
71
+ await this.#o.storage.put(this.stateKey, state);
72
+ }
73
+ async context() {
74
+ return await this.#o.storage.get(this.contextKey);
75
+ }
76
+ /**
77
+ * Record which run this is, **before** spawning.
78
+ *
79
+ * The order is the whole point: a drain captures `startedAt` after the spawn,
80
+ * so a context written afterwards would let two runs share a generation.
81
+ */
82
+ async putContext(context) {
83
+ await this.#o.storage.put(this.contextKey, context);
84
+ }
85
+ // --- arming ----------------------------------------------------------------
86
+ /**
87
+ * Hand a cold job to the alarm, if one is not already pending.
88
+ *
89
+ * Returns the stamp it armed with, or `undefined` when it declined — the
90
+ * caller needs the stamp because it is what the alarm must present to
91
+ * {@link claim} to get past the single-flight guard.
92
+ *
93
+ * An arming caller must **not** own the run. The predecessor handed one to
94
+ * `ctx.waitUntil` from a gate poll that returned in milliseconds, and the
95
+ * drain was disposed underneath it mid-command. An alarm invocation belongs to
96
+ * the object rather than to any request, so nothing it awaits can be cut short.
97
+ */
98
+ async arm(placeholder) {
99
+ const state = await this.read();
100
+ if (!isRearmable(state))
101
+ return undefined;
102
+ const lastArmed = await this.#o.storage.get(this.lastArmedKey);
103
+ if (lastArmed !== undefined &&
104
+ Date.now() - lastArmed < this.#o.armCooldownMs)
105
+ return undefined;
106
+ const armedAt = Date.now();
107
+ await this.write({
108
+ ...placeholder,
109
+ state: "running",
110
+ startedAt: armedAt
111
+ });
112
+ await this.#o.storage.put(this.armedKey, armedAt);
113
+ // Kept even if the scheduling below fails, deliberately: a floor that only
114
+ // applied to *successful* arming would let a persistently failing schedule
115
+ // re-arm on every call into the object, which is what it exists to prevent.
116
+ await this.#o.storage.put(this.lastArmedKey, armedAt);
117
+ /**
118
+ * The placeholder and the alarm that owns it are two writes, and between
119
+ * them is the one window where this can strand a job: a `running` record no
120
+ * run intent points at, which every later {@link arm} then declines to
121
+ * replace *because* it is running.
122
+ *
123
+ * The staleness bound in {@link claim} would eventually free it, but only
124
+ * after a full timeout — so unwind instead, and leave the record exactly as
125
+ * re-armable as it was found.
126
+ */
127
+ try {
128
+ await this.#o.wake.set({ key: this.runIntent, notBefore: armedAt });
129
+ }
130
+ catch (err) {
131
+ await this.write(state);
132
+ await this.#o.storage.delete(this.armedKey).catch(() => { });
133
+ throw err;
134
+ }
135
+ return armedAt;
136
+ }
137
+ /** The stamp {@link arm} wrote, so the alarm can recognise its own placeholder. */
138
+ async armedAt() {
139
+ return await this.#o.storage.get(this.armedKey);
140
+ }
141
+ async clearArmed() {
142
+ await this.#o.storage.delete(this.armedKey);
143
+ }
144
+ // --- the single-flight guard ------------------------------------------------
145
+ /**
146
+ * Decide whether a new run may start.
147
+ *
148
+ * `takeOverArmedAt` is the one exemption and it is narrow on purpose. The
149
+ * alarm's placeholder *is* a `running` record for a job that has not started,
150
+ * so the alarm has to pass its own guard — and only its own. Matching the
151
+ * exact stamp it wrote is what stops this becoming "take over any running
152
+ * job", which is the displacement bug the guard exists to prevent: three
153
+ * callers spawning under one exec id in fifty seconds, each displacing the
154
+ * last, every displaced drain still attached and still writing verdicts.
155
+ *
156
+ * Applies the staleness bound **itself**, rather than trusting the caller to
157
+ * have repaired the record first. An earlier draft took an
158
+ * "already-repaired" state and said so in prose, which enforced nothing: the
159
+ * repaired and raw types are identical, so a caller passing a raw read got a
160
+ * `running` record that could never be claimed and a job wedged forever.
161
+ * `timeoutMs` is the job's own budget; see {@link isStale}.
162
+ */
163
+ claim(state, timeoutMs, takeOverArmedAt) {
164
+ if (state.state !== "running")
165
+ return { ok: true };
166
+ // The alarm presenting its own placeholder — the one narrow exemption.
167
+ if (state.startedAt === takeOverArmedAt)
168
+ return { ok: true };
169
+ // A record whose isolate is gone must not block every later run.
170
+ if (this.isStale(state, timeoutMs))
171
+ return { ok: true };
172
+ return { ok: false, current: state };
173
+ }
174
+ // --- staleness and re-attach -------------------------------------------------
175
+ /**
176
+ * Whether a `running` record has stood long enough to be presumed dead.
177
+ *
178
+ * `timeoutMs` is the job's own budget; the bound is that plus `staleMs`, so a
179
+ * job that is merely slow is never declared gone.
180
+ */
181
+ isStale(state, timeoutMs, now = Date.now()) {
182
+ return now - state.startedAt > timeoutMs + this.#o.staleMs;
183
+ }
184
+ /** Arm the watchdog that re-attaches to a job nobody is draining. */
185
+ async armWatch(now = Date.now()) {
186
+ await this.#o.wake.set({
187
+ key: this.watchIntent,
188
+ notBefore: now + this.#o.watchMs
189
+ });
190
+ }
191
+ /**
192
+ * Disarm the watchdog.
193
+ *
194
+ * Never call this from a superseded drain: the watchdog belongs to whichever
195
+ * run owns the record *now*, and clearing it there disarms the one recovery
196
+ * path the live run has.
197
+ */
198
+ async clearWatch() {
199
+ await this.#o.wake.clear(this.watchIntent).catch(() => { });
200
+ }
201
+ // --- generation --------------------------------------------------------------
202
+ /**
203
+ * A predicate a drain calls before every write, to ask whether it still owns
204
+ * the record.
205
+ *
206
+ * Captures the stamp once, at drain start, and compares it against disk each
207
+ * time. The closure also latches, so a drain can ask afterwards whether it was
208
+ * superseded — which is what decides if it may touch the watchdog.
209
+ */
210
+ generation(startedAt) {
211
+ let superseded = false;
212
+ return {
213
+ stillMine: async () => {
214
+ // The latch is checked *before* the read, not after. Ownership is not
215
+ // recoverable: once another run has owned this record, a stamp that
216
+ // happens to match again does not hand it back, and a drain that
217
+ // regained write access here would be the corruption the marker exists
218
+ // to prevent.
219
+ if (superseded)
220
+ return false;
221
+ const now = await this.context();
222
+ if (now?.startedAt === startedAt)
223
+ return true;
224
+ superseded = true;
225
+ return false;
226
+ },
227
+ superseded: () => superseded
228
+ };
229
+ }
230
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The record an alarm-owned job writes about itself, and the shape a gate reads.
3
+ *
4
+ * This is the contract between two halves that do not own each other: the
5
+ * Durable Object *runs* the job, and something else — a shell tool, a subagent
6
+ * executor — refuses to proceed while one is in flight. A shape they agreed on
7
+ * informally would drift, and the drift shows up as a command running halfway
8
+ * through the job it was supposed to wait for.
9
+ *
10
+ * ## Why `TExtra` intersects rather than nests
11
+ *
12
+ * The obvious generic is `{ state: "running"; meta: TExtra }`. It is wrong here,
13
+ * and expensively so: every existing reader spells the job's own field at the
14
+ * top level (`status.command`), so nesting would rewrite every read site and
15
+ * every spec assertion in both consumers to buy nothing. Intersecting keeps
16
+ * `JobState<{ command: string }>` *byte-identical* to the hand-written union it
17
+ * replaces, which is what makes adopting this a type change and not a refactor.
18
+ *
19
+ * The cost of the choice is that `TExtra` must not collide with the field names
20
+ * below. That is a real constraint, and it is why they are named for the
21
+ * mechanism (`startedAt`, `finishedAt`, `exitCode`) rather than for any job.
22
+ */
23
+ /**
24
+ * A job's durable state.
25
+ *
26
+ * Five variants, and the two that look redundant are not:
27
+ *
28
+ * - `idle` — nothing has ever run. There is no context recording *where* or
29
+ * *what*, so a caller cannot re-drive it; that is the owner's job.
30
+ * - `skipped` — something looked and decided there was nothing to do. Terminal
31
+ * and *correct*, which is why it is not `done`: a gate must not treat a
32
+ * deliberate no-op as a failure to retry, and an arming path must not re-drive
33
+ * it forever.
34
+ * - `running` — in flight, or believed to be. Never trusted without the
35
+ * staleness bound in {@link JobLifecycle.claim}, because the isolate that
36
+ * wrote it may be long gone.
37
+ * - `done` / `failed` — terminal, carrying enough to explain the outcome without
38
+ * the caller reaching for the transcript.
39
+ */
40
+ /** Nothing has ever run; no context exists naming what would. */
41
+ export type IdleJob = {
42
+ state: "idle";
43
+ };
44
+ /** Something looked and decided there was nothing to do. Terminal and correct. */
45
+ export type SkippedJob = {
46
+ state: "skipped";
47
+ reason: string;
48
+ };
49
+ /** In flight, or believed to be. Never trusted without the staleness bound. */
50
+ export type RunningJob<TExtra = Record<never, never>> = {
51
+ state: "running";
52
+ startedAt: number;
53
+ tail?: string;
54
+ } & TExtra;
55
+ export type DoneJob<TExtra = Record<never, never>> = {
56
+ state: "done";
57
+ exitCode: number;
58
+ finishedAt: number;
59
+ ms: number;
60
+ tail?: string;
61
+ } & TExtra;
62
+ export type FailedJob<TExtra = Record<never, never>> = {
63
+ state: "failed";
64
+ finishedAt: number;
65
+ error: string;
66
+ exitCode?: number;
67
+ tail?: string;
68
+ } & TExtra;
69
+ /**
70
+ * A job's durable state.
71
+ *
72
+ * The variants are named types rather than inlined into the union because
73
+ * `Extract<JobState<TExtra>, { state: "running" }>` cannot narrow while `TExtra`
74
+ * is generic — the compiler has no way to prove `DoneJob & TExtra` does not also
75
+ * carry `state: "running"`. Naming them is what lets a caller say
76
+ * `RunningJob<TExtra>` and get its fields.
77
+ */
78
+ export type JobState<TExtra = Record<never, never>> = IdleJob | SkippedJob | RunningJob<TExtra> | DoneJob<TExtra> | FailedJob<TExtra>;
79
+ /**
80
+ * Whether a state is one a new run may start from.
81
+ *
82
+ * `done` and `failed` both qualify, and the second was a gap worth closing in
83
+ * the predecessor: arming used to require `done`, so one bad run left a record
84
+ * that declined to re-arm forever — one failure poisoning every task after it.
85
+ *
86
+ * `skipped` and `idle` are excluded for different reasons. `skipped` means the
87
+ * answer is already correct and permanent. `idle` means no context exists naming
88
+ * what to run, so there is nothing to re-drive.
89
+ */
90
+ export declare function isRearmable<TExtra extends object>(state: JobState<TExtra>): boolean;
91
+ /** Whether a state claims a job is in flight. Never conclusive on its own. */
92
+ export declare function isRunning<TExtra extends object>(state: JobState<TExtra>): state is RunningJob<TExtra>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The record an alarm-owned job writes about itself, and the shape a gate reads.
3
+ *
4
+ * This is the contract between two halves that do not own each other: the
5
+ * Durable Object *runs* the job, and something else — a shell tool, a subagent
6
+ * executor — refuses to proceed while one is in flight. A shape they agreed on
7
+ * informally would drift, and the drift shows up as a command running halfway
8
+ * through the job it was supposed to wait for.
9
+ *
10
+ * ## Why `TExtra` intersects rather than nests
11
+ *
12
+ * The obvious generic is `{ state: "running"; meta: TExtra }`. It is wrong here,
13
+ * and expensively so: every existing reader spells the job's own field at the
14
+ * top level (`status.command`), so nesting would rewrite every read site and
15
+ * every spec assertion in both consumers to buy nothing. Intersecting keeps
16
+ * `JobState<{ command: string }>` *byte-identical* to the hand-written union it
17
+ * replaces, which is what makes adopting this a type change and not a refactor.
18
+ *
19
+ * The cost of the choice is that `TExtra` must not collide with the field names
20
+ * below. That is a real constraint, and it is why they are named for the
21
+ * mechanism (`startedAt`, `finishedAt`, `exitCode`) rather than for any job.
22
+ */
23
+ /**
24
+ * Whether a state is one a new run may start from.
25
+ *
26
+ * `done` and `failed` both qualify, and the second was a gap worth closing in
27
+ * the predecessor: arming used to require `done`, so one bad run left a record
28
+ * that declined to re-arm forever — one failure poisoning every task after it.
29
+ *
30
+ * `skipped` and `idle` are excluded for different reasons. `skipped` means the
31
+ * answer is already correct and permanent. `idle` means no context exists naming
32
+ * what to run, so there is nothing to re-drive.
33
+ */
34
+ export function isRearmable(state) {
35
+ return state.state === "done" || state.state === "failed";
36
+ }
37
+ /** Whether a state claims a job is in flight. Never conclusive on its own. */
38
+ export function isRunning(state) {
39
+ return state.state === "running";
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loopingai/core",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Shared, mandatory foundation for Looping agents on Cloudflare Workers: zero-trust A2A, durable task lifecycle, delegation and subagent runtime, test harness.",
5
5
  "keywords": [
6
6
  "a2a",
@@ -61,6 +61,10 @@
61
61
  "types": "./dist/alarm/index.d.ts",
62
62
  "import": "./dist/alarm/index.js"
63
63
  },
64
+ "./job": {
65
+ "types": "./dist/job/index.d.ts",
66
+ "import": "./dist/job/index.js"
67
+ },
64
68
  "./round": {
65
69
  "types": "./dist/round/index.d.ts",
66
70
  "import": "./dist/round/index.js"
@@ -108,7 +112,8 @@
108
112
  "test:watch": "vitest",
109
113
  "verify:exports": "node scripts/verify-exports.mjs",
110
114
  "prepack": "npm run build && npm run verify:exports",
111
- "prepublishOnly": "npm run check && npm test && npm run verify:exports"
115
+ "prepublishOnly": "npm run check && npm test && npm run verify:exports",
116
+ "prepare": "husky"
112
117
  },
113
118
  "dependencies": {
114
119
  "@a2a-js/sdk": "^1.0.0",
@@ -144,6 +149,7 @@
144
149
  "ai": "^7.0.52",
145
150
  "drizzle-kit": "^0.31.10",
146
151
  "eslint": "^10.8.0",
152
+ "husky": "^9.1.7",
147
153
  "prettier": "^3.9.6",
148
154
  "typescript": "^6.0.3",
149
155
  "typescript-eslint": "^8.66.0",