@loopingai/core 0.8.0 → 0.8.2
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/dist/a2a/deliver.d.ts +110 -0
- package/dist/a2a/deliver.js +140 -4
- package/dist/a2a/index.d.ts +1 -1
- package/dist/a2a/index.js +1 -1
- package/dist/db/models/tasks.d.ts +13 -0
- package/dist/db/models/tasks.js +33 -0
- package/dist/job/index.d.ts +21 -0
- package/dist/job/index.js +21 -0
- package/dist/job/lifecycle.d.ts +176 -0
- package/dist/job/lifecycle.js +230 -0
- package/dist/job/state.d.ts +92 -0
- package/dist/job/state.js +40 -0
- package/dist/round/workflow.d.ts +26 -9
- package/dist/round/workflow.js +63 -5
- package/package.json +8 -2
package/dist/a2a/deliver.d.ts
CHANGED
|
@@ -29,6 +29,40 @@ export interface DeliverTerminalOptions {
|
|
|
29
29
|
* An agent with no managed children omits it and no `sweep` step appears.
|
|
30
30
|
*/
|
|
31
31
|
sweep?: () => Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Prefix for this delivery's three step names. Empty by default, which is the
|
|
34
|
+
* only value an ordinary turn may use.
|
|
35
|
+
*
|
|
36
|
+
* **Step names are durable cache keys**, so this is not cosmetic. A workflow
|
|
37
|
+
* that delivers twice — an ordinary delivery, then
|
|
38
|
+
* {@link deliverAbandonedTask} from a `catch` above it — would otherwise call
|
|
39
|
+
* `step.do("complete")` a second time and be handed the *first* call's cached
|
|
40
|
+
* result, so the failed Task it built would never be written and the outcome
|
|
41
|
+
* would depend on Workflows' caching semantics for a step whose failure was
|
|
42
|
+
* caught. A distinct prefix makes the second delivery its own set of steps,
|
|
43
|
+
* where the guarded write decides the outcome as it is supposed to.
|
|
44
|
+
*/
|
|
45
|
+
stepPrefix?: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Thrown when a delivery failed *after* its terminal Task was durably saved.
|
|
49
|
+
*
|
|
50
|
+
* The distinction it carries is the difference between "this turn produced
|
|
51
|
+
* nothing" and "this turn produced a result the gateway has not heard about
|
|
52
|
+
* yet", and only the first of those is an abandoned task.
|
|
53
|
+
*
|
|
54
|
+
* Without it, an ordinary delivery whose `notify` step exhausted its retries
|
|
55
|
+
* would unwind into an abandoned-task recovery, which would write a generic
|
|
56
|
+
* failure over a real answer. The guarded write refuses that now
|
|
57
|
+
* ({@link file://../db/models/tasks.ts}), but refusing it is not enough on its
|
|
58
|
+
* own: the recovery would then succeed at doing nothing, swallow a genuine
|
|
59
|
+
* callback failure, and log the word "abandoned" about a turn that completed.
|
|
60
|
+
* So the two work together — this stops the recovery being attempted, and the
|
|
61
|
+
* guard is the backstop for the narrow window where a `complete` step throws
|
|
62
|
+
* without saying whether its write applied.
|
|
63
|
+
*/
|
|
64
|
+
export declare class TaskAlreadyTerminalError extends Error {
|
|
65
|
+
constructor(cause: unknown);
|
|
32
66
|
}
|
|
33
67
|
/**
|
|
34
68
|
* Persist a turn's terminal Task, then notify the gateway.
|
|
@@ -47,3 +81,79 @@ export interface DeliverTerminalOptions {
|
|
|
47
81
|
* it. Everything it calls already lives here.
|
|
48
82
|
*/
|
|
49
83
|
export declare function deliverTerminalTask(step: WorkflowStep, options: DeliverTerminalOptions): Promise<void>;
|
|
84
|
+
/** What {@link deliverAbandonedTask} needs to end a turn nobody else will. */
|
|
85
|
+
export interface AbandonedTaskOptions {
|
|
86
|
+
/** Which Task this is about, and where its callback goes. */
|
|
87
|
+
push: TurnPushContext;
|
|
88
|
+
/** The agent's card-signing key, for the callback JWT. */
|
|
89
|
+
signingKey: string;
|
|
90
|
+
/** The guarded write — same contract as {@link DeliverTerminalOptions.saveTask}. */
|
|
91
|
+
saveTask: (task: PlainTask) => Promise<boolean>;
|
|
92
|
+
/**
|
|
93
|
+
* The user-facing words. The host's, always: core ships no prompt copy and no
|
|
94
|
+
* user-facing copy, and a paraphrase invented at the failure site is exactly
|
|
95
|
+
* what this parameter exists to prevent.
|
|
96
|
+
*/
|
|
97
|
+
text: string;
|
|
98
|
+
/** Best-effort child cleanup. Omitted by an agent that delegates to nothing. */
|
|
99
|
+
sweep?: () => Promise<void>;
|
|
100
|
+
/** Log prefix — conventionally the agent's tenant id. */
|
|
101
|
+
label?: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Turn an unrecoverable orchestration fault into a delivered failed Task.
|
|
105
|
+
*
|
|
106
|
+
* ## The failure this exists for
|
|
107
|
+
*
|
|
108
|
+
* A turn ends badly in two different ways. A **typed** failure — the models were
|
|
109
|
+
* tried and nothing usable came back — is a value, and the ordinary delivery
|
|
110
|
+
* path carries it. A **transient** fault instead throws, so the step retries and
|
|
111
|
+
* recovers without paying for a second inference. That is the right default.
|
|
112
|
+
*
|
|
113
|
+
* What neither covers is a transient fault that never stops being one. `step.do`
|
|
114
|
+
* retries a bounded number of times and then rethrows, and with nothing above it
|
|
115
|
+
* to catch that, the orchestration unwinds, the delivery path is never reached,
|
|
116
|
+
* and the Workflow instance errors with the Task still sitting in `working`. The
|
|
117
|
+
* user is told nothing at all — and because the instance dies mid-`run`, the
|
|
118
|
+
* runtime records it as *"your Worker's code had hung and would never generate a
|
|
119
|
+
* response"*, which reads like a bug in the workflow rather than a provider that
|
|
120
|
+
* was refusing every request.
|
|
121
|
+
*
|
|
122
|
+
* Observed exactly that way in a deployed agent on 2026-08-19: a turn step
|
|
123
|
+
* exhausted its four attempts against a rate-limited provider, and the agent
|
|
124
|
+
* simply went quiet.
|
|
125
|
+
*
|
|
126
|
+
* ## Three behaviours here are load-bearing
|
|
127
|
+
*
|
|
128
|
+
* **It resolves after a successful delivery — it does not rethrow.** Rethrowing
|
|
129
|
+
* would reproduce the very "hung Worker" record this exists to remove, and the
|
|
130
|
+
* instance genuinely has finished its job: the Task is terminal and the gateway
|
|
131
|
+
* has been told.
|
|
132
|
+
*
|
|
133
|
+
* **It rethrows the *original* `cause` when the delivery itself fails.**
|
|
134
|
+
* Swallowing there would mark the instance successful while the user got
|
|
135
|
+
* nothing — strictly worse than the erroring instance being replaced, because it
|
|
136
|
+
* would also be silent in the Workflows console. `cause` is what an operator
|
|
137
|
+
* needs to see, not the delivery's own secondary fault.
|
|
138
|
+
*
|
|
139
|
+
* **It refuses to run at all once a terminal Task exists.** A
|
|
140
|
+
* {@link TaskAlreadyTerminalError} means the ordinary delivery persisted its
|
|
141
|
+
* result and only the callback failed, so the original fault is rethrown and
|
|
142
|
+
* nothing is written. An earlier draft of this claimed the guarded write alone
|
|
143
|
+
* made that safe; it did not. The write refused only cancellation conflicts, so
|
|
144
|
+
* `completed → failed` applied cleanly and a turn that succeeded would have been
|
|
145
|
+
* rewritten as a generic failure because a webhook was flaky. The guard now
|
|
146
|
+
* refuses any terminal-over-different-terminal write as a backstop, and this
|
|
147
|
+
* check is what stops the attempt being made in the first place — without it the
|
|
148
|
+
* recovery would quietly succeed at doing nothing, swallowing a real callback
|
|
149
|
+
* failure and logging "abandoned" about a completed turn.
|
|
150
|
+
*
|
|
151
|
+
* ## Why it is exported rather than private to `/round`
|
|
152
|
+
*
|
|
153
|
+
* `runHandleTask` wraps itself in it, so every round agent gets this without
|
|
154
|
+
* asking. But an agent whose turn is a single inference writes its own workflow
|
|
155
|
+
* body and has the identical exposure — and the alternative to exporting this is
|
|
156
|
+
* that each such host reimplements the delivery, which is precisely the mistake
|
|
157
|
+
* this function was extracted from.
|
|
158
|
+
*/
|
|
159
|
+
export declare function deliverAbandonedTask(step: WorkflowStep, cause: unknown, options: AbandonedTaskOptions): Promise<void>;
|
package/dist/a2a/deliver.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
+
import { buildFailedTask } from "./notify.js";
|
|
1
2
|
import { createPushChannel } from "./push.js";
|
|
3
|
+
/**
|
|
4
|
+
* Thrown when a delivery failed *after* its terminal Task was durably saved.
|
|
5
|
+
*
|
|
6
|
+
* The distinction it carries is the difference between "this turn produced
|
|
7
|
+
* nothing" and "this turn produced a result the gateway has not heard about
|
|
8
|
+
* yet", and only the first of those is an abandoned task.
|
|
9
|
+
*
|
|
10
|
+
* Without it, an ordinary delivery whose `notify` step exhausted its retries
|
|
11
|
+
* would unwind into an abandoned-task recovery, which would write a generic
|
|
12
|
+
* failure over a real answer. The guarded write refuses that now
|
|
13
|
+
* ({@link file://../db/models/tasks.ts}), but refusing it is not enough on its
|
|
14
|
+
* own: the recovery would then succeed at doing nothing, swallow a genuine
|
|
15
|
+
* callback failure, and log the word "abandoned" about a turn that completed.
|
|
16
|
+
* So the two work together — this stops the recovery being attempted, and the
|
|
17
|
+
* guard is the backstop for the narrow window where a `complete` step throws
|
|
18
|
+
* without saying whether its write applied.
|
|
19
|
+
*/
|
|
20
|
+
export class TaskAlreadyTerminalError extends Error {
|
|
21
|
+
constructor(cause) {
|
|
22
|
+
super("the terminal Task was saved; the callback after it failed", {
|
|
23
|
+
cause
|
|
24
|
+
});
|
|
25
|
+
this.name = "TaskAlreadyTerminalError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
2
28
|
/**
|
|
3
29
|
* Persist a turn's terminal Task, then notify the gateway.
|
|
4
30
|
*
|
|
@@ -16,7 +42,8 @@ import { createPushChannel } from "./push.js";
|
|
|
16
42
|
* it. Everything it calls already lives here.
|
|
17
43
|
*/
|
|
18
44
|
export async function deliverTerminalTask(step, options) {
|
|
19
|
-
const
|
|
45
|
+
const at = options.stepPrefix ?? "";
|
|
46
|
+
const task = await step.do(`${at}complete`, async () => {
|
|
20
47
|
const terminal = options.terminal();
|
|
21
48
|
return (await options.saveTask(terminal)) ? terminal : null;
|
|
22
49
|
});
|
|
@@ -28,7 +55,7 @@ export async function deliverTerminalTask(step, options) {
|
|
|
28
55
|
if (options.sweep) {
|
|
29
56
|
const sweep = options.sweep;
|
|
30
57
|
try {
|
|
31
|
-
await step.do(
|
|
58
|
+
await step.do(`${at}sweep`, async () => {
|
|
32
59
|
await sweep();
|
|
33
60
|
});
|
|
34
61
|
}
|
|
@@ -43,7 +70,116 @@ export async function deliverTerminalTask(step, options) {
|
|
|
43
70
|
// terminal messageId is deterministic and the gateway is idempotent and
|
|
44
71
|
// single-use, so retries are safe. If it ultimately fails, the gateway's own
|
|
45
72
|
// reaction backstop clears the pending marker.
|
|
46
|
-
|
|
47
|
-
|
|
73
|
+
//
|
|
74
|
+
// Wrapped, because by this line the Task is durably terminal: anything that
|
|
75
|
+
// fails from here is a callback that did not land, never a turn that produced
|
|
76
|
+
// nothing. See {@link TaskAlreadyTerminalError}.
|
|
77
|
+
try {
|
|
78
|
+
await step.do(`${at}notify`, async () => {
|
|
79
|
+
await createPushChannel(options.signingKey, options.push).deliver(task);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw new TaskAlreadyTerminalError(err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The step-name prefix an abandoned delivery runs under.
|
|
88
|
+
*
|
|
89
|
+
* Its own namespace so it can never collide with the ordinary delivery's — see
|
|
90
|
+
* {@link DeliverTerminalOptions.stepPrefix}.
|
|
91
|
+
*/
|
|
92
|
+
const ABANDONED_PREFIX = "abandoned:";
|
|
93
|
+
/**
|
|
94
|
+
* Turn an unrecoverable orchestration fault into a delivered failed Task.
|
|
95
|
+
*
|
|
96
|
+
* ## The failure this exists for
|
|
97
|
+
*
|
|
98
|
+
* A turn ends badly in two different ways. A **typed** failure — the models were
|
|
99
|
+
* tried and nothing usable came back — is a value, and the ordinary delivery
|
|
100
|
+
* path carries it. A **transient** fault instead throws, so the step retries and
|
|
101
|
+
* recovers without paying for a second inference. That is the right default.
|
|
102
|
+
*
|
|
103
|
+
* What neither covers is a transient fault that never stops being one. `step.do`
|
|
104
|
+
* retries a bounded number of times and then rethrows, and with nothing above it
|
|
105
|
+
* to catch that, the orchestration unwinds, the delivery path is never reached,
|
|
106
|
+
* and the Workflow instance errors with the Task still sitting in `working`. The
|
|
107
|
+
* user is told nothing at all — and because the instance dies mid-`run`, the
|
|
108
|
+
* runtime records it as *"your Worker's code had hung and would never generate a
|
|
109
|
+
* response"*, which reads like a bug in the workflow rather than a provider that
|
|
110
|
+
* was refusing every request.
|
|
111
|
+
*
|
|
112
|
+
* Observed exactly that way in a deployed agent on 2026-08-19: a turn step
|
|
113
|
+
* exhausted its four attempts against a rate-limited provider, and the agent
|
|
114
|
+
* simply went quiet.
|
|
115
|
+
*
|
|
116
|
+
* ## Three behaviours here are load-bearing
|
|
117
|
+
*
|
|
118
|
+
* **It resolves after a successful delivery — it does not rethrow.** Rethrowing
|
|
119
|
+
* would reproduce the very "hung Worker" record this exists to remove, and the
|
|
120
|
+
* instance genuinely has finished its job: the Task is terminal and the gateway
|
|
121
|
+
* has been told.
|
|
122
|
+
*
|
|
123
|
+
* **It rethrows the *original* `cause` when the delivery itself fails.**
|
|
124
|
+
* Swallowing there would mark the instance successful while the user got
|
|
125
|
+
* nothing — strictly worse than the erroring instance being replaced, because it
|
|
126
|
+
* would also be silent in the Workflows console. `cause` is what an operator
|
|
127
|
+
* needs to see, not the delivery's own secondary fault.
|
|
128
|
+
*
|
|
129
|
+
* **It refuses to run at all once a terminal Task exists.** A
|
|
130
|
+
* {@link TaskAlreadyTerminalError} means the ordinary delivery persisted its
|
|
131
|
+
* result and only the callback failed, so the original fault is rethrown and
|
|
132
|
+
* nothing is written. An earlier draft of this claimed the guarded write alone
|
|
133
|
+
* made that safe; it did not. The write refused only cancellation conflicts, so
|
|
134
|
+
* `completed → failed` applied cleanly and a turn that succeeded would have been
|
|
135
|
+
* rewritten as a generic failure because a webhook was flaky. The guard now
|
|
136
|
+
* refuses any terminal-over-different-terminal write as a backstop, and this
|
|
137
|
+
* check is what stops the attempt being made in the first place — without it the
|
|
138
|
+
* recovery would quietly succeed at doing nothing, swallowing a real callback
|
|
139
|
+
* failure and logging "abandoned" about a completed turn.
|
|
140
|
+
*
|
|
141
|
+
* ## Why it is exported rather than private to `/round`
|
|
142
|
+
*
|
|
143
|
+
* `runHandleTask` wraps itself in it, so every round agent gets this without
|
|
144
|
+
* asking. But an agent whose turn is a single inference writes its own workflow
|
|
145
|
+
* body and has the identical exposure — and the alternative to exporting this is
|
|
146
|
+
* that each such host reimplements the delivery, which is precisely the mistake
|
|
147
|
+
* this function was extracted from.
|
|
148
|
+
*/
|
|
149
|
+
export async function deliverAbandonedTask(step, cause, options) {
|
|
150
|
+
// Nothing was abandoned: the ordinary delivery already persisted a terminal
|
|
151
|
+
// Task and only its callback failed. Rethrow the fault that actually
|
|
152
|
+
// happened, so the instance still errors exactly as it did before any
|
|
153
|
+
// recovery existed and the gateway's own backstop clears the pending marker.
|
|
154
|
+
//
|
|
155
|
+
// Checked **here** rather than in each caller's `catch` on purpose. This whole
|
|
156
|
+
// change exists because a recovery every host had to remember to wire was one
|
|
157
|
+
// three hosts forgot; a caveat every host had to remember to check would be
|
|
158
|
+
// the same mistake a second time.
|
|
159
|
+
if (cause instanceof TaskAlreadyTerminalError)
|
|
160
|
+
throw cause.cause;
|
|
161
|
+
const label = options.label ?? "agent";
|
|
162
|
+
// Logged before anything is attempted: if the delivery below also fails, this
|
|
163
|
+
// line is the only record of what actually went wrong.
|
|
164
|
+
console.error(`[${label}] task abandoned after retries were exhausted`, {
|
|
165
|
+
taskId: options.push.taskId,
|
|
166
|
+
error: String(cause)
|
|
48
167
|
});
|
|
168
|
+
try {
|
|
169
|
+
await deliverTerminalTask(step, {
|
|
170
|
+
push: options.push,
|
|
171
|
+
signingKey: options.signingKey,
|
|
172
|
+
saveTask: options.saveTask,
|
|
173
|
+
terminal: () => buildFailedTask(options.push.taskId, options.push.contextId, options.text),
|
|
174
|
+
sweep: options.sweep,
|
|
175
|
+
stepPrefix: ABANDONED_PREFIX
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (deliveryFailed) {
|
|
179
|
+
console.error(`[${label}] could not deliver the failed Task`, {
|
|
180
|
+
taskId: options.push.taskId,
|
|
181
|
+
error: String(deliveryFailed)
|
|
182
|
+
});
|
|
183
|
+
throw cause;
|
|
184
|
+
}
|
|
49
185
|
}
|
package/dist/a2a/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { A2A_JWS_ALG, audienceFor, endpointUrl, jwksUrl } from "@loopingai/a2a-p
|
|
|
22
22
|
export { IDENTITY_CLAIM, TENANT_CLAIM, GatewayAuthError, bearerToken, normalizeGatewayOrigins, verifyGatewayToken, type GatewayIdentity, type VerifyOptions } from "./verify.js";
|
|
23
23
|
export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publicCardJwks, type AgentManifest, type BuildCardOptions, type CardSigningConfig, type WireAgentCard } from "./card.js";
|
|
24
24
|
export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
|
|
25
|
-
export { deliverTerminalTask, type DeliverTerminalOptions } from "./deliver.js";
|
|
25
|
+
export { deliverTerminalTask, deliverAbandonedTask, TaskAlreadyTerminalError, type DeliverTerminalOptions, type AbandonedTaskOptions } from "./deliver.js";
|
|
26
26
|
export { signCallerToken, type CallerTokenOptions } from "./caller-token.js";
|
|
27
27
|
export { SelfOrigin } from "./self-origin.js";
|
|
28
28
|
export { callerContext } from "./caller.js";
|
package/dist/a2a/index.js
CHANGED
|
@@ -22,7 +22,7 @@ export { A2A_JWS_ALG, audienceFor, endpointUrl, jwksUrl } from "@loopingai/a2a-p
|
|
|
22
22
|
export { IDENTITY_CLAIM, TENANT_CLAIM, GatewayAuthError, bearerToken, normalizeGatewayOrigins, verifyGatewayToken } from "./verify.js";
|
|
23
23
|
export { A2A_RPC_PATH, buildBaseCard, signCard, wireCard, parsePrivateJwk, publicCardJwks } from "./card.js";
|
|
24
24
|
export { NOTIFICATION_TOKEN_HEADER, buildSubmittedTask, buildWorkingTask, buildCompletedTask, buildFailedTask, buildNoReplyCompletedTask, signCallbackJwt, postNotification } from "./notify.js";
|
|
25
|
-
export { deliverTerminalTask } from "./deliver.js";
|
|
25
|
+
export { deliverTerminalTask, deliverAbandonedTask, TaskAlreadyTerminalError } from "./deliver.js";
|
|
26
26
|
export { signCallerToken } from "./caller-token.js";
|
|
27
27
|
export { SelfOrigin } from "./self-origin.js";
|
|
28
28
|
export { callerContext } from "./caller.js";
|
|
@@ -78,6 +78,19 @@ export declare function makeTasks(db: DB): {
|
|
|
78
78
|
* `canceled` onto a `submitted`/`working` row, or re-writing it onto an
|
|
79
79
|
* already-`canceled` one, stays allowed: that is how the a2a-js handler's own
|
|
80
80
|
* cancel branch records the cancellation.
|
|
81
|
+
*
|
|
82
|
+
* **And no terminal row may be replaced by a *different* terminal state.**
|
|
83
|
+
* The two rules above were written about cancellation and between them left
|
|
84
|
+
* `completed → failed` wide open, which is not hypothetical: a workflow whose
|
|
85
|
+
* `notify` step exhausts its retries throws *after* `complete` durably saved
|
|
86
|
+
* a completed Task, and an abandoned-task recovery above it would then write
|
|
87
|
+
* a generic failure over a real answer and post a callback contradicting it.
|
|
88
|
+
* A turn that succeeded would be recorded as having failed because a webhook
|
|
89
|
+
* was flaky.
|
|
90
|
+
*
|
|
91
|
+
* Same terminal state re-written is still allowed, and must be: a Workflow
|
|
92
|
+
* replay legitimately re-runs `complete` and saves what it already saved, and
|
|
93
|
+
* refusing that would suppress the callback that replay exists to send.
|
|
81
94
|
*/
|
|
82
95
|
save(task: Task): boolean;
|
|
83
96
|
/**
|
package/dist/db/models/tasks.js
CHANGED
|
@@ -15,6 +15,21 @@ function nowIso() {
|
|
|
15
15
|
export function stateOf(task) {
|
|
16
16
|
return task.status?.state ?? TaskState.TASK_STATE_UNSPECIFIED;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* The states a task never leaves.
|
|
20
|
+
*
|
|
21
|
+
* `INPUT_REQUIRED` and `AUTH_REQUIRED` are deliberately absent: a turn parked on
|
|
22
|
+
* either is waiting, not finished, and will move again.
|
|
23
|
+
*/
|
|
24
|
+
const TERMINAL_STATES = new Set([
|
|
25
|
+
TaskState.TASK_STATE_COMPLETED,
|
|
26
|
+
TaskState.TASK_STATE_FAILED,
|
|
27
|
+
TaskState.TASK_STATE_CANCELED,
|
|
28
|
+
TaskState.TASK_STATE_REJECTED
|
|
29
|
+
]);
|
|
30
|
+
function isTerminal(state) {
|
|
31
|
+
return TERMINAL_STATES.has(state);
|
|
32
|
+
}
|
|
18
33
|
/**
|
|
19
34
|
* Query methods for the `notify_tasks` table (async A2A task state).
|
|
20
35
|
*
|
|
@@ -143,6 +158,19 @@ export function makeTasks(db) {
|
|
|
143
158
|
* `canceled` onto a `submitted`/`working` row, or re-writing it onto an
|
|
144
159
|
* already-`canceled` one, stays allowed: that is how the a2a-js handler's own
|
|
145
160
|
* cancel branch records the cancellation.
|
|
161
|
+
*
|
|
162
|
+
* **And no terminal row may be replaced by a *different* terminal state.**
|
|
163
|
+
* The two rules above were written about cancellation and between them left
|
|
164
|
+
* `completed → failed` wide open, which is not hypothetical: a workflow whose
|
|
165
|
+
* `notify` step exhausts its retries throws *after* `complete` durably saved
|
|
166
|
+
* a completed Task, and an abandoned-task recovery above it would then write
|
|
167
|
+
* a generic failure over a real answer and post a callback contradicting it.
|
|
168
|
+
* A turn that succeeded would be recorded as having failed because a webhook
|
|
169
|
+
* was flaky.
|
|
170
|
+
*
|
|
171
|
+
* Same terminal state re-written is still allowed, and must be: a Workflow
|
|
172
|
+
* replay legitimately re-runs `complete` and saves what it already saved, and
|
|
173
|
+
* refusing that would suppress the callback that replay exists to send.
|
|
146
174
|
*/
|
|
147
175
|
save(task) {
|
|
148
176
|
const existing = readOne(task.id);
|
|
@@ -162,6 +190,11 @@ export function makeTasks(db) {
|
|
|
162
190
|
existingState !== TaskState.TASK_STATE_CANCELED) {
|
|
163
191
|
return false;
|
|
164
192
|
}
|
|
193
|
+
if (isTerminal(existingState) &&
|
|
194
|
+
isTerminal(incomingState) &&
|
|
195
|
+
incomingState !== existingState) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
165
198
|
upsert(task);
|
|
166
199
|
return true;
|
|
167
200
|
},
|
|
@@ -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/dist/round/workflow.d.ts
CHANGED
|
@@ -102,6 +102,13 @@ export interface HandleTaskDeps {
|
|
|
102
102
|
* with no change here.
|
|
103
103
|
*/
|
|
104
104
|
signingKey: string;
|
|
105
|
+
/**
|
|
106
|
+
* Log prefix for the abandoned-task line, conventionally the agent's tenant
|
|
107
|
+
* id. Optional because nothing here needs it to work — but a deployment that
|
|
108
|
+
* mounts several agents on one Worker gets one log stream, and without this
|
|
109
|
+
* every one of them reports going quiet under the same name.
|
|
110
|
+
*/
|
|
111
|
+
label?: string;
|
|
105
112
|
}
|
|
106
113
|
/**
|
|
107
114
|
* The caller's agent DO stub — every phase runs through it.
|
|
@@ -112,19 +119,29 @@ export interface HandleTaskDeps {
|
|
|
112
119
|
*/
|
|
113
120
|
type AgentStub = DurableObjectStub<RoundAgentBase>;
|
|
114
121
|
/**
|
|
115
|
-
* The orchestration
|
|
116
|
-
*
|
|
122
|
+
* The orchestration, split from the `WorkflowEntrypoint` wiring so it can be
|
|
123
|
+
* driven with a fake `step` in tests (workerd forbids constructing a
|
|
117
124
|
* `WorkflowEntrypoint` outside the runtime) — and so a second agent can reuse it
|
|
118
125
|
* with different deps.
|
|
119
126
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
127
|
+
* ## What the wrapper adds, and why it is not the host's job
|
|
128
|
+
*
|
|
129
|
+
* Core distinguishes two ways a turn ends badly. A **typed** failure is a value
|
|
130
|
+
* and {@link deliver} carries it. A **transient** fault throws, so the step
|
|
131
|
+
* retries and recovers from the durable rows without paying for a second
|
|
132
|
+
* inference. Neither covers a transient fault that never stops being one: the
|
|
133
|
+
* step exhausts its retries, {@link orchestrate} unwinds, the delivery below is
|
|
134
|
+
* never reached, and the instance errors with the Task still in `working` — the
|
|
135
|
+
* user told nothing, and the runtime recording a hang. See
|
|
136
|
+
* {@link deliverAbandonedTask}, which was written for a deployed agent that did
|
|
137
|
+
* exactly this on 2026-08-19.
|
|
124
138
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
139
|
+
* This is caught **here** rather than left to each `WorkflowEntrypoint` because
|
|
140
|
+
* everything the recovery needs is already in {@link HandleTaskDeps}: the stub
|
|
141
|
+
* (typed on `RoundAgentBase`, so `saveTask` and `sweepTaskChildren` are both
|
|
142
|
+
* reachable), `policy.copy.taskFailed`, and `signingKey`. A host has nothing to
|
|
143
|
+
* add — so asking it to remember buys nothing and costs exactly what it cost the
|
|
144
|
+
* starter, where three of four agents never wrote the `catch` at all.
|
|
128
145
|
*/
|
|
129
146
|
export declare function runHandleTask(p: HandleTaskParams, step: WorkflowStep, deps: HandleTaskDeps): Promise<void>;
|
|
130
147
|
export {};
|
package/dist/round/workflow.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CHUNK_STEP, MAX_CHUNKS_PER_BRANCH, STEP_TIMEOUT_MS } from "../platform.js";
|
|
2
2
|
import { buildCompletedTask, buildFailedTask } from "../a2a/notify.js";
|
|
3
|
-
import { deliverTerminalTask } from "../a2a/deliver.js";
|
|
3
|
+
import { deliverAbandonedTask, deliverTerminalTask } from "../a2a/deliver.js";
|
|
4
4
|
/**
|
|
5
5
|
* The same retries, and a timeout a **round** can actually be measured against.
|
|
6
6
|
*
|
|
@@ -31,11 +31,67 @@ function turnStep(config) {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
* The orchestration
|
|
35
|
-
*
|
|
34
|
+
* The orchestration, split from the `WorkflowEntrypoint` wiring so it can be
|
|
35
|
+
* driven with a fake `step` in tests (workerd forbids constructing a
|
|
36
36
|
* `WorkflowEntrypoint` outside the runtime) — and so a second agent can reuse it
|
|
37
37
|
* with different deps.
|
|
38
38
|
*
|
|
39
|
+
* ## What the wrapper adds, and why it is not the host's job
|
|
40
|
+
*
|
|
41
|
+
* Core distinguishes two ways a turn ends badly. A **typed** failure is a value
|
|
42
|
+
* and {@link deliver} carries it. A **transient** fault throws, so the step
|
|
43
|
+
* retries and recovers from the durable rows without paying for a second
|
|
44
|
+
* inference. Neither covers a transient fault that never stops being one: the
|
|
45
|
+
* step exhausts its retries, {@link orchestrate} unwinds, the delivery below is
|
|
46
|
+
* never reached, and the instance errors with the Task still in `working` — the
|
|
47
|
+
* user told nothing, and the runtime recording a hang. See
|
|
48
|
+
* {@link deliverAbandonedTask}, which was written for a deployed agent that did
|
|
49
|
+
* exactly this on 2026-08-19.
|
|
50
|
+
*
|
|
51
|
+
* This is caught **here** rather than left to each `WorkflowEntrypoint` because
|
|
52
|
+
* everything the recovery needs is already in {@link HandleTaskDeps}: the stub
|
|
53
|
+
* (typed on `RoundAgentBase`, so `saveTask` and `sweepTaskChildren` are both
|
|
54
|
+
* reachable), `policy.copy.taskFailed`, and `signingKey`. A host has nothing to
|
|
55
|
+
* add — so asking it to remember buys nothing and costs exactly what it cost the
|
|
56
|
+
* starter, where three of four agents never wrote the `catch` at all.
|
|
57
|
+
*/
|
|
58
|
+
export async function runHandleTask(p, step, deps) {
|
|
59
|
+
try {
|
|
60
|
+
await orchestrate(p, step, deps);
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
// Everything this needs is already in `deps` — which is the argument for it
|
|
64
|
+
// living here rather than in each host's `catch`. Four agents in the starter
|
|
65
|
+
// called this function and only one had written that `catch`; the other three
|
|
66
|
+
// carried the 2026-08-19 failure silently. A guard nobody can forget is worth
|
|
67
|
+
// more than a helper everybody must remember.
|
|
68
|
+
await deliverAbandonedTask(step, cause, {
|
|
69
|
+
push: {
|
|
70
|
+
taskId: p.taskId,
|
|
71
|
+
contextId: p.contextId,
|
|
72
|
+
pushUrl: p.pushUrl,
|
|
73
|
+
pushToken: p.pushToken,
|
|
74
|
+
jku: p.jku
|
|
75
|
+
},
|
|
76
|
+
signingKey: deps.signingKey,
|
|
77
|
+
// Resolved inside each closure, never hoisted — see {@link ResolveAgent}.
|
|
78
|
+
saveTask: (task) => deps.resolveAgent(p.identity).saveTask(task),
|
|
79
|
+
// The round never got far enough to say *which* credential or model was at
|
|
80
|
+
// fault, so `failureCopy` has nothing to answer and the policy's own words
|
|
81
|
+
// are the honest ones. The diagnostic is logged instead.
|
|
82
|
+
text: deps.policy.copy.taskFailed,
|
|
83
|
+
sweep: async () => {
|
|
84
|
+
await deps.resolveAgent(p.identity).sweepTaskChildren(p.taskId);
|
|
85
|
+
},
|
|
86
|
+
label: deps.label
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The orchestration proper — every ordinary outcome ends inside here, and
|
|
92
|
+
* anything that escapes is what {@link runHandleTask} turns into a delivered
|
|
93
|
+
* failure.
|
|
94
|
+
*
|
|
39
95
|
* Every `step.do` return here is a small projection — a status, an id, a reply.
|
|
40
96
|
* Never a Subtask row: a step return is capped at 1 MiB and a Subtask carries
|
|
41
97
|
* verbatim history snapshots, so the rows stay in the DO and the Workflow carries
|
|
@@ -43,9 +99,11 @@ function turnStep(config) {
|
|
|
43
99
|
*
|
|
44
100
|
* **Step names are durable cache keys.** Everything inside the round loop carries
|
|
45
101
|
* its round for that reason: `turn:<round>`, `deadline:<round>`, `scan:<round>`,
|
|
46
|
-
* `cancel:<round>`. Renaming one silently re-runs its effect on replay
|
|
102
|
+
* `cancel:<round>`. Renaming one silently re-runs its effect on replay — and the
|
|
103
|
+
* recovery path in {@link runHandleTask} runs under its own prefix for the same
|
|
104
|
+
* reason, so a second delivery cannot be handed this one's cached results.
|
|
47
105
|
*/
|
|
48
|
-
|
|
106
|
+
async function orchestrate(p, step, deps) {
|
|
49
107
|
const limits = deps.config.mainAgentLimits;
|
|
50
108
|
// Pre-work. Routing is pure, so it needs no step of its own — but it is
|
|
51
109
|
// deliberately *not* resolved here into a value the steps below close over.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loopingai/core",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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",
|