@particle-academy/prism-harness 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/doctor.d.ts +35 -0
- package/dist/doctor.js +110 -0
- package/dist/errors.d.ts +199 -0
- package/dist/errors.js +201 -0
- package/dist/events.d.ts +67 -0
- package/dist/events.js +40 -0
- package/dist/harness.d.ts +51 -0
- package/dist/harness.js +70 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +20 -0
- package/dist/json.d.ts +15 -0
- package/dist/json.js +15 -0
- package/dist/modes.d.ts +63 -0
- package/dist/modes.js +109 -0
- package/dist/runtime.d.ts +104 -0
- package/dist/runtime.js +244 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.js +198 -0
- package/dist/skills.d.ts +37 -0
- package/dist/skills.js +73 -0
- package/dist/store-task-source.d.ts +169 -0
- package/dist/store-task-source.js +513 -0
- package/dist/stores/file-session-store.d.ts +34 -0
- package/dist/stores/file-session-store.js +284 -0
- package/dist/stores/memory-session-store.d.ts +22 -0
- package/dist/stores/memory-session-store.js +97 -0
- package/dist/stores/session-store.d.ts +65 -0
- package/dist/stores/session-store.js +3 -0
- package/dist/stores/store-manager.d.ts +32 -0
- package/dist/stores/store-manager.js +57 -0
- package/dist/subagent-runner.d.ts +45 -0
- package/dist/subagent-runner.js +89 -0
- package/dist/subagents.d.ts +182 -0
- package/dist/subagents.js +286 -0
- package/dist/task-tools.d.ts +55 -0
- package/dist/task-tools.js +136 -0
- package/dist/tasks.d.ts +257 -0
- package/dist/tasks.js +162 -0
- package/dist/thread.d.ts +49 -0
- package/dist/thread.js +88 -0
- package/dist/tools.d.ts +89 -0
- package/dist/tools.js +148 -0
- package/package.json +34 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { isJsonObject } from './json.js';
|
|
2
|
+
import { HarnessError } from './errors.js';
|
|
3
|
+
import { isDurable } from './stores/session-store.js';
|
|
4
|
+
import { DEFAULT_LEASE_SECONDS, isTerminalState, taskRecordFrom, } from './tasks.js';
|
|
5
|
+
/**
|
|
6
|
+
* A task whose state was read at a point in time.
|
|
7
|
+
*
|
|
8
|
+
* A SNAPSHOT, and it has to be: `AgentTask.state()` is synchronous, and the
|
|
9
|
+
* only honest way to make it live would be to hit the store, which cannot be
|
|
10
|
+
* done without returning a promise. Ask `StoreTaskSource.find()` for a fresh
|
|
11
|
+
* read; do not treat a task object held across an await as current.
|
|
12
|
+
*/
|
|
13
|
+
export class StoredAgentTask {
|
|
14
|
+
#record;
|
|
15
|
+
constructor(record) {
|
|
16
|
+
this.#record = { ...record };
|
|
17
|
+
}
|
|
18
|
+
id() {
|
|
19
|
+
return this.#record.id;
|
|
20
|
+
}
|
|
21
|
+
instruction() {
|
|
22
|
+
return this.#record.instruction;
|
|
23
|
+
}
|
|
24
|
+
state() {
|
|
25
|
+
return this.#record.state;
|
|
26
|
+
}
|
|
27
|
+
claimedBy() {
|
|
28
|
+
return this.#record.claimed_by;
|
|
29
|
+
}
|
|
30
|
+
claimedUntil() {
|
|
31
|
+
return this.#record.claimed_until;
|
|
32
|
+
}
|
|
33
|
+
toRecord() {
|
|
34
|
+
return { ...this.#record };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The default task source: the list lives in the harness's own durable store.
|
|
39
|
+
*
|
|
40
|
+
* No schema, no migration, works on install. A consumer with an existing table
|
|
41
|
+
* adapts it instead (`asAgentTask` / `withAgentTask`); both satisfy one
|
|
42
|
+
* contract, and neither is the afterthought.
|
|
43
|
+
*
|
|
44
|
+
* ## Durable or nothing
|
|
45
|
+
*
|
|
46
|
+
* REFUSED AT CONSTRUCTION against a store that reports itself volatile. The
|
|
47
|
+
* package already draws this line for the durable slot and this inherits the
|
|
48
|
+
* rule rather than restating it: a half-finished task list that vanishes on a
|
|
49
|
+
* deploy is indistinguishable from a finished one, so losing it is a
|
|
50
|
+
* correctness failure and not a degradation to a default.
|
|
51
|
+
*
|
|
52
|
+
* ## The claim is one operation
|
|
53
|
+
*
|
|
54
|
+
* `claim()` does the whole read-expire-select-write inside ONE store lock.
|
|
55
|
+
* Reading the next task and then marking it taken is two operations with a
|
|
56
|
+
* window between them, and two workers arriving in that window both get the
|
|
57
|
+
* same task. That race is the reason this class exists, so the lock wraps
|
|
58
|
+
* everything rather than just the write.
|
|
59
|
+
*
|
|
60
|
+
* **The guarantee is the STORE'S, not this class's.** "Never the same task
|
|
61
|
+
* twice" holds exactly as far as `SessionStore.withLock` does:
|
|
62
|
+
* `MemorySessionStore`'s lock is process-local and `FileSessionStore`'s is one
|
|
63
|
+
* machine. Two workers on different machines over a network filesystem are not
|
|
64
|
+
* excluded by either, and no file lock can promise that -- point the durable
|
|
65
|
+
* slot at a database or Redis there. Saying so is the point: a claim that looks
|
|
66
|
+
* atomic and is not would hand the same expensive task to two agents.
|
|
67
|
+
*
|
|
68
|
+
* ## Size
|
|
69
|
+
*
|
|
70
|
+
* The whole list is one stored value, read and rewritten on every claim. That
|
|
71
|
+
* is right for the tens-of-tasks a bounded agent run produces and wrong for
|
|
72
|
+
* thousands: a consumer with a real backlog wants their own table and the
|
|
73
|
+
* application-model adapter, which is why that adapter is a first-class shape
|
|
74
|
+
* rather than an afterthought.
|
|
75
|
+
*
|
|
76
|
+
* ## Ordering
|
|
77
|
+
*
|
|
78
|
+
* Tasks are held in an array and `claim()` takes the first claimable one, so
|
|
79
|
+
* the order out is the order in. Nothing here sorts, prioritises or shuffles.
|
|
80
|
+
* Ordering is the divergence nothing reports: the agent just does the work in a
|
|
81
|
+
* different sequence and produces a different result.
|
|
82
|
+
*/
|
|
83
|
+
export class StoreTaskSource {
|
|
84
|
+
#store;
|
|
85
|
+
#key;
|
|
86
|
+
#now;
|
|
87
|
+
constructor(store, key, options = {}) {
|
|
88
|
+
// Checked here, before anything can be written, because "refuse to start"
|
|
89
|
+
// is the requirement -- not "refuse once the first task is lost".
|
|
90
|
+
if (!isDurable(store.durability())) {
|
|
91
|
+
throw HarnessError.volatileTaskSource();
|
|
92
|
+
}
|
|
93
|
+
this.#store = store;
|
|
94
|
+
this.#key = key;
|
|
95
|
+
this.#now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
96
|
+
}
|
|
97
|
+
/** What the underlying store says about itself. Always `durable` -- see the constructor. */
|
|
98
|
+
durability() {
|
|
99
|
+
return this.#store.durability();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The current time, FLOORED TO A WHOLE SECOND.
|
|
103
|
+
*
|
|
104
|
+
* Every timestamp this class writes is derived from here, so flooring once is
|
|
105
|
+
* what keeps `claimed_until` an integer. JavaScript has no int/float
|
|
106
|
+
* distinction, so a fractional clock produced `1090.75` and passed every
|
|
107
|
+
* equality assertion that had been written about it -- the bytes were
|
|
108
|
+
* `"claimed_until":1090.75` where the other two ports write `1090`, and only
|
|
109
|
+
* a `Number.isInteger` check on the stored value could see it. That test
|
|
110
|
+
* exists, and it found this.
|
|
111
|
+
*/
|
|
112
|
+
#seconds() {
|
|
113
|
+
return Math.floor(this.#now());
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Append a task, in `todo`.
|
|
117
|
+
*
|
|
118
|
+
* The id may be supplied -- a consumer seeding a list from their own domain
|
|
119
|
+
* keys wants their keys -- and is generated as `t-<n>` when it is not. A
|
|
120
|
+
* supplied id that is already in the list is REFUSED rather than merged: two
|
|
121
|
+
* units of work sharing an id would share a claim.
|
|
122
|
+
*
|
|
123
|
+
* An id of `""` is refused too, compared against the empty string EXACTLY.
|
|
124
|
+
* Nothing is trimmed first, in any of the three languages: `trim`, `strip`
|
|
125
|
+
* and `String.prototype.trim` each strip a different set of codepoints, so a
|
|
126
|
+
* trim here would give three different answers to the same input. A single
|
|
127
|
+
* space is a legal id, and that is the correct behaviour.
|
|
128
|
+
*/
|
|
129
|
+
async add(instruction, id) {
|
|
130
|
+
if (id !== undefined && id === '') {
|
|
131
|
+
throw HarnessError.taskIdentifierBlank('task id');
|
|
132
|
+
}
|
|
133
|
+
return this.#store.withLock(this.#key, async () => {
|
|
134
|
+
const stored = await this.#read();
|
|
135
|
+
if (id !== undefined && stored.records.some((record) => record.id === id)) {
|
|
136
|
+
throw HarnessError.duplicateTaskId(id);
|
|
137
|
+
}
|
|
138
|
+
const taken = new Set(stored.records.map((record) => record.id));
|
|
139
|
+
let nextId = stored.nextId;
|
|
140
|
+
// A generated id must not land on one a caller supplied earlier.
|
|
141
|
+
while (id === undefined && taken.has(`t-${nextId}`))
|
|
142
|
+
nextId += 1;
|
|
143
|
+
const record = makeRecord(id ?? `t-${nextId}`, instruction, 'todo', null, null);
|
|
144
|
+
await this.#write([...stored.records, record], id === undefined ? nextId + 1 : stored.nextId);
|
|
145
|
+
return new StoredAgentTask(record);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** Append several tasks at once, in the order given, with generated ids. */
|
|
149
|
+
async addMany(instructions) {
|
|
150
|
+
const added = [];
|
|
151
|
+
for (const instruction of instructions)
|
|
152
|
+
added.push(await this.add(instruction));
|
|
153
|
+
return added;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Take the next claimable task, atomically, or null when there is none.
|
|
157
|
+
*
|
|
158
|
+
* The claim is WRITTEN BEFORE this returns, which is the point: the caller
|
|
159
|
+
* cannot begin work until the store says the task is theirs, so a worker that
|
|
160
|
+
* dies mid-task leaves a `claimed` row with an expiry rather than a `todo`
|
|
161
|
+
* row that looks like it was never attempted.
|
|
162
|
+
*/
|
|
163
|
+
async claim(worker, leaseSeconds = DEFAULT_LEASE_SECONDS) {
|
|
164
|
+
assertIdentifier(worker, 'worker id');
|
|
165
|
+
const lease = assertLease(leaseSeconds);
|
|
166
|
+
return this.#store.withLock(this.#key, async () => {
|
|
167
|
+
const now = this.#seconds();
|
|
168
|
+
const stored = await this.#read();
|
|
169
|
+
const { records, changed } = expireLeases(stored.records, now);
|
|
170
|
+
const index = records.findIndex((record) => record.state === 'todo');
|
|
171
|
+
if (index === -1) {
|
|
172
|
+
// Nothing to claim, but an expiry we noticed is a fact about the list
|
|
173
|
+
// and is persisted anyway. A reader must not see `claimed` for a lease
|
|
174
|
+
// that ran out just because the claim that noticed came up empty.
|
|
175
|
+
if (changed)
|
|
176
|
+
await this.#write(records, stored.nextId);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const previous = records[index];
|
|
180
|
+
const claimed = makeRecord(previous.id, previous.instruction, 'claimed', worker, now + lease);
|
|
181
|
+
records[index] = claimed;
|
|
182
|
+
await this.#write(records, stored.nextId);
|
|
183
|
+
return new StoredAgentTask(claimed);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Record what happened, as the worker HOLDING the task. Both outcomes are TERMINAL.
|
|
188
|
+
*
|
|
189
|
+
* Refuses a task that is already terminal rather than ignoring it: two
|
|
190
|
+
* callers believing they own one outcome is worth reporting, and the second
|
|
191
|
+
* write silently winning would leave nothing recording the disagreement.
|
|
192
|
+
*
|
|
193
|
+
* Refuses a task this worker is not holding -- nobody holding it, someone
|
|
194
|
+
* else holding it, or a lease that has since expired all reach the same
|
|
195
|
+
* refusal, because they have the same answer. The state machine has no edge
|
|
196
|
+
* from `todo` to `done`, and a lease that expired mid-flight has already put
|
|
197
|
+
* the task back where anyone can take it, so the worker that finally finished
|
|
198
|
+
* may no longer be the one whose result counts.
|
|
199
|
+
*
|
|
200
|
+
* And refuses an OUTCOME that is not one of the two, BEFORE anything else --
|
|
201
|
+
* see `assertOutcome`, and the ordering note there.
|
|
202
|
+
*/
|
|
203
|
+
async release(task, worker, outcome) {
|
|
204
|
+
const recorded = assertOutcome(outcome);
|
|
205
|
+
assertIdentifier(worker, 'worker id');
|
|
206
|
+
await this.#store.withLock(this.#key, async () => {
|
|
207
|
+
const now = this.#seconds();
|
|
208
|
+
const stored = await this.#read();
|
|
209
|
+
const index = stored.records.findIndex((record) => record.id === task.id());
|
|
210
|
+
if (index === -1) {
|
|
211
|
+
throw HarnessError.taskNotFound(task.id());
|
|
212
|
+
}
|
|
213
|
+
const raw = stored.records[index];
|
|
214
|
+
const { records, changed } = expireLeases(stored.records, now);
|
|
215
|
+
const refusal = refuseUnheld(raw, worker, now);
|
|
216
|
+
if (refusal !== null) {
|
|
217
|
+
if (changed)
|
|
218
|
+
await this.#write(records, stored.nextId);
|
|
219
|
+
throw refusal;
|
|
220
|
+
}
|
|
221
|
+
// The owner and the expiry go to null. A terminal task is held by nobody,
|
|
222
|
+
// and leaving a stale holder on it would make `claimed_by` mean two
|
|
223
|
+
// different things depending on the state it sits beside.
|
|
224
|
+
records[index] = makeRecord(raw.id, raw.instruction, recorded, null, null);
|
|
225
|
+
await this.#write(records, stored.nextId);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* How many tasks a `claim()` could still hand out.
|
|
230
|
+
*
|
|
231
|
+
* Counts `todo` AND any `claimed` task whose lease has run out, because an
|
|
232
|
+
* expired lease is claimable by anyone -- a loop that stopped while such a
|
|
233
|
+
* task sat there would stop with work left.
|
|
234
|
+
*
|
|
235
|
+
* Reads without taking the lock. A store `get` is a single atomic read of one
|
|
236
|
+
* key, so the count is consistent with some moment; taking the lock would
|
|
237
|
+
* make it consistent with a moment that has already passed by the time the
|
|
238
|
+
* caller reads it.
|
|
239
|
+
*/
|
|
240
|
+
async pending() {
|
|
241
|
+
const now = this.#seconds();
|
|
242
|
+
const { records } = expireLeases((await this.#read()).records, now);
|
|
243
|
+
return records.filter((record) => record.state === 'todo').length;
|
|
244
|
+
}
|
|
245
|
+
/** A fresh read of one task, with any expired lease already applied, or null. */
|
|
246
|
+
async find(id) {
|
|
247
|
+
const now = this.#seconds();
|
|
248
|
+
const { records } = expireLeases((await this.#read()).records, now);
|
|
249
|
+
const record = records.find((candidate) => candidate.id === id);
|
|
250
|
+
return record === undefined ? null : new StoredAgentTask(record);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Push this worker's lease out, and ONLY while it still holds it.
|
|
254
|
+
*
|
|
255
|
+
* Bounded by the RUN, which is the whole argument. Unbounded self-extension
|
|
256
|
+
* is how a wedged worker holds a task forever, and a second timeout invented
|
|
257
|
+
* here would be one limit spelled two ways across an ecosystem -- which is
|
|
258
|
+
* how a limit ends up set in the place that is not enforced. The `RunBudget`
|
|
259
|
+
* is already the stop condition, so extension stops when the run's own
|
|
260
|
+
* allowance does. Nothing new to enforce and nothing new to forget.
|
|
261
|
+
*
|
|
262
|
+
* TWO checks, not one. `exhaustion()` refuses outright -- cancelled, out of
|
|
263
|
+
* steps, out of money or out of time -- and `remainingSeconds()` then bounds
|
|
264
|
+
* how much is granted. Reading the spec's "remaining wall-clock budget"
|
|
265
|
+
* literally and checking only the second left a cancelled run able to keep
|
|
266
|
+
* extending; the reference settled it the other way.
|
|
267
|
+
*
|
|
268
|
+
* Granted seconds are `min(requested, remaining)`. A budget with no
|
|
269
|
+
* wall-clock cap (`maxSeconds` null) grants what was asked, matching
|
|
270
|
+
* `RunLedger.exhaustion()`, which also has nothing to enforce in that case.
|
|
271
|
+
*
|
|
272
|
+
* The new expiry is `now + granted` EVEN WHEN THAT SHORTENS the lease. A
|
|
273
|
+
* first draft here took the later of the two, on the reasoning that a call
|
|
274
|
+
* named "extend" should never take time away. That is the wrong reasoning:
|
|
275
|
+
* the granted figure is what the RUN can still afford, so keeping a longer
|
|
276
|
+
* lease would hold a task past the budget that is supposed to bound it, and
|
|
277
|
+
* it would also make the result depend on the order calls happened to arrive
|
|
278
|
+
* in. One rule, one answer.
|
|
279
|
+
*
|
|
280
|
+
* @returns the new expiry, an integer Unix timestamp.
|
|
281
|
+
*/
|
|
282
|
+
async extendLease(task, worker, seconds, ledger, budget) {
|
|
283
|
+
assertIdentifier(worker, 'worker id');
|
|
284
|
+
const requested = assertLease(seconds);
|
|
285
|
+
return this.#store.withLock(this.#key, async () => {
|
|
286
|
+
const now = this.#seconds();
|
|
287
|
+
const stored = await this.#read();
|
|
288
|
+
const index = stored.records.findIndex((record) => record.id === task.id());
|
|
289
|
+
if (index === -1) {
|
|
290
|
+
throw HarnessError.taskNotFound(task.id());
|
|
291
|
+
}
|
|
292
|
+
// Judged against the row AS STORED, not the expired-normalised copy, so
|
|
293
|
+
// the message can still say which of the three cases it was even though
|
|
294
|
+
// all three share one code.
|
|
295
|
+
const raw = stored.records[index];
|
|
296
|
+
const { records, changed } = expireLeases(stored.records, now);
|
|
297
|
+
const refusal = refuseUnheld(raw, worker, now);
|
|
298
|
+
if (refusal !== null) {
|
|
299
|
+
if (changed)
|
|
300
|
+
await this.#write(records, stored.nextId);
|
|
301
|
+
throw refusal;
|
|
302
|
+
}
|
|
303
|
+
// EXHAUSTION, not only the clock. The spec names the remaining
|
|
304
|
+
// wall-clock budget, and reading that literally was this port's first
|
|
305
|
+
// answer -- which left a CANCELLED run, or one that had spent its steps
|
|
306
|
+
// or its money, still able to push its lease out and hold a task it may
|
|
307
|
+
// no longer do anything with. `exhaustion()` is the existing stop
|
|
308
|
+
// condition and returns the REASON, so nothing new is invented and the
|
|
309
|
+
// four causes stay distinguishable.
|
|
310
|
+
const exhausted = ledger.exhaustion(budget);
|
|
311
|
+
if (exhausted !== null) {
|
|
312
|
+
if (changed)
|
|
313
|
+
await this.#write(records, stored.nextId);
|
|
314
|
+
throw HarnessError.runNotPermitted(`The lease held by [${worker}] on the task [${raw.id}] cannot be extended: ${exhausted}. ` +
|
|
315
|
+
'The run is the bound on how long a worker may hold a task, and extending past it would ' +
|
|
316
|
+
'be a second timeout wearing the first one\'s name.');
|
|
317
|
+
}
|
|
318
|
+
const remaining = ledger.remainingSeconds(budget);
|
|
319
|
+
const granted = remaining === null ? requested : Math.min(requested, remaining);
|
|
320
|
+
const until = now + granted;
|
|
321
|
+
records[index] = makeRecord(raw.id, raw.instruction, 'claimed', worker, until);
|
|
322
|
+
await this.#write(records, stored.nextId);
|
|
323
|
+
return until;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Read the list back, refusing anything it cannot map.
|
|
328
|
+
*
|
|
329
|
+
* A junk entry is an ERROR, not something to filter out. The thread does
|
|
330
|
+
* filter, and can afford to: a dropped message degrades a conversation. A
|
|
331
|
+
* dropped task is work that will never be done and nothing anywhere says so
|
|
332
|
+
* -- and a list that quietly shrinks is the failure mode this whole
|
|
333
|
+
* capability exists to prevent, arriving through the back door.
|
|
334
|
+
*/
|
|
335
|
+
async #read() {
|
|
336
|
+
const stored = (await this.#store.get(this.#key)) ?? {};
|
|
337
|
+
const tasks = stored.tasks;
|
|
338
|
+
const records = Array.isArray(tasks)
|
|
339
|
+
? tasks.map((entry) => {
|
|
340
|
+
if (!isJsonObject(entry)) {
|
|
341
|
+
throw HarnessError.unmappableContent(`a stored task list entry is [${typeof entry}] rather than an object`);
|
|
342
|
+
}
|
|
343
|
+
return taskRecordFrom(entry);
|
|
344
|
+
})
|
|
345
|
+
: [];
|
|
346
|
+
const nextId = typeof stored.next_id === 'number' ? Math.trunc(stored.next_id) : records.length + 1;
|
|
347
|
+
return { records, nextId };
|
|
348
|
+
}
|
|
349
|
+
async #write(records, nextId) {
|
|
350
|
+
const payload = {
|
|
351
|
+
tasks: records.map((record) => ({ ...record })),
|
|
352
|
+
next_id: nextId,
|
|
353
|
+
};
|
|
354
|
+
await this.#store.put(this.#key, payload);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The five canonical keys, always all of them, always in sorted order.
|
|
359
|
+
*
|
|
360
|
+
* Built through one function so no call site can forget a claim field and leave
|
|
361
|
+
* an `undefined` behind. `undefined` would survive every type check that only
|
|
362
|
+
* looks at the values it was given and then vanish at serialisation, taking the
|
|
363
|
+
* key with it.
|
|
364
|
+
*/
|
|
365
|
+
function makeRecord(id, instruction, state, claimedBy, claimedUntil) {
|
|
366
|
+
return {
|
|
367
|
+
claimed_by: claimedBy,
|
|
368
|
+
claimed_until: claimedUntil,
|
|
369
|
+
id,
|
|
370
|
+
instruction,
|
|
371
|
+
state,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Return every task whose lease has run out to `todo`.
|
|
376
|
+
*
|
|
377
|
+
* TO `todo`, NEVER TO `failed`. A worker dying is not the task failing, and
|
|
378
|
+
* marking it failed burns a retry that never ran -- the application would then
|
|
379
|
+
* have to re-queue work that was never attempted, if it noticed at all.
|
|
380
|
+
*
|
|
381
|
+
* The expiry test is `claimed_until <= now`: the timestamp is the instant the
|
|
382
|
+
* lease ENDS, so at that instant it is over.
|
|
383
|
+
*/
|
|
384
|
+
function expireLeases(records, now) {
|
|
385
|
+
let changed = false;
|
|
386
|
+
const next = records.map((record) => {
|
|
387
|
+
if (!leaseExpired(record, now))
|
|
388
|
+
return { ...record };
|
|
389
|
+
changed = true;
|
|
390
|
+
return makeRecord(record.id, record.instruction, 'todo', null, null);
|
|
391
|
+
});
|
|
392
|
+
return { records: next, changed };
|
|
393
|
+
}
|
|
394
|
+
function leaseExpired(record, now) {
|
|
395
|
+
return record.state === 'claimed' && record.claimed_until !== null && record.claimed_until <= now;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Why this worker may not act on this task -- or null when it may.
|
|
399
|
+
*
|
|
400
|
+
* TWO codes, not four. "Already finished" is its own, because the fix is to
|
|
401
|
+
* stop; the other three -- nobody holds it, someone else does, your lease ran
|
|
402
|
+
* out -- share `task_lease_not_held`, because the fix for all three is to claim
|
|
403
|
+
* it again. The sentence still distinguishes them for a human reading a log.
|
|
404
|
+
*
|
|
405
|
+
* The detail NEVER NAMES THE HOLDER. This refusal can surface inside a tool
|
|
406
|
+
* call, where a failure reaches the model as readable text, and an agent has no
|
|
407
|
+
* business learning the identity of a peer worker from an error message.
|
|
408
|
+
*/
|
|
409
|
+
function refuseUnheld(record, worker, now) {
|
|
410
|
+
if (isTerminalState(record.state)) {
|
|
411
|
+
return HarnessError.taskAlreadyTerminal(record.id, record.state);
|
|
412
|
+
}
|
|
413
|
+
if (record.state !== 'claimed') {
|
|
414
|
+
return HarnessError.taskLeaseNotHeld(record.id, worker, 'nothing is holding it');
|
|
415
|
+
}
|
|
416
|
+
// Checked before expiry, deliberately. A lease that was never this worker's
|
|
417
|
+
// is not this worker's whether or not it has run out.
|
|
418
|
+
if (record.claimed_by !== worker) {
|
|
419
|
+
return HarnessError.taskLeaseNotHeld(record.id, worker, 'another worker is holding it');
|
|
420
|
+
}
|
|
421
|
+
if (leaseExpired(record, now)) {
|
|
422
|
+
return HarnessError.taskLeaseNotHeld(record.id, worker, 'its lease has expired');
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Refuse the empty string, and NOTHING ELSE.
|
|
428
|
+
*
|
|
429
|
+
* Compared against `""` exactly, with no trimming. PHP's `trim`, JavaScript's
|
|
430
|
+
* `String.prototype.trim` and Python's `str.strip` each strip a different set
|
|
431
|
+
* of codepoints, so trimming here would mean three ports disagreeing about
|
|
432
|
+
* whether a given id is blank -- the shape of `prism-human-plus` G-36, where a
|
|
433
|
+
* single trailing space defeated a guard in all three languages at once. A
|
|
434
|
+
* worker id of one space is accepted on purpose.
|
|
435
|
+
*/
|
|
436
|
+
function assertIdentifier(value, what) {
|
|
437
|
+
if (value === '') {
|
|
438
|
+
throw HarnessError.taskIdentifierBlank(what);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Exactly `done` or `failed`. Anything else is refused, never coerced.
|
|
443
|
+
*
|
|
444
|
+
* `TaskOutcome` is a union of string literals, which means it exists at COMPILE
|
|
445
|
+
* TIME AND NOWHERE ELSE. The annotation on `release()` stops nothing at run
|
|
446
|
+
* time: an outcome arriving from a queue payload, an HTTP body, a JSON config
|
|
447
|
+
* or any JavaScript caller reaches this method as an ordinary string, and the
|
|
448
|
+
* parameter is `unknown` here for the same reason `assertLease`'s is. Every
|
|
449
|
+
* door, not just the typed one.
|
|
450
|
+
*
|
|
451
|
+
* ## What made this a hole rather than a naming difference
|
|
452
|
+
*
|
|
453
|
+
* Found by `prism-parity/suites/agent-task-claim` (atc-0011, atc-0012), and it
|
|
454
|
+
* needed the cross-language corpus to see: the reference converts the string
|
|
455
|
+
* through a `TaskOutcome` ENUM at the call site and Python does the same, so
|
|
456
|
+
* both refuse before anything is written. Without this guard, `"complete"` was
|
|
457
|
+
* written into the DURABLE LIST as a `state` that is not one of the four, and
|
|
458
|
+
* nothing refused until a reader mapped the row back -- `unmappable_content`,
|
|
459
|
+
* for every language that opens that list. A TypeScript writer could poison a
|
|
460
|
+
* list a PHP or Python worker then could not read, and the language that
|
|
461
|
+
* reported the failure was not the one that caused it. See G-39.
|
|
462
|
+
*
|
|
463
|
+
* ## Refused FIRST, before the worker id and the holder check
|
|
464
|
+
*
|
|
465
|
+
* The order is observable and is chosen to match the reference, where the
|
|
466
|
+
* conversion happens in the argument expression and therefore before `release()`
|
|
467
|
+
* is entered at all. So a call that is wrong in two ways at once -- a blank
|
|
468
|
+
* worker AND an invalid outcome -- reports the outcome in all three languages.
|
|
469
|
+
* Nothing in the corpus pins this; it is pinned in this package's own suite.
|
|
470
|
+
*/
|
|
471
|
+
function assertOutcome(outcome) {
|
|
472
|
+
if (outcome !== 'done' && outcome !== 'failed') {
|
|
473
|
+
throw HarnessError.taskOutcomeInvalid('StoreTaskSource.release()', outcome);
|
|
474
|
+
}
|
|
475
|
+
return outcome;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* A lease of WHOLE POSITIVE SECONDS. Anything else is refused, never adjusted.
|
|
479
|
+
*
|
|
480
|
+
* Two rules that are really one rule, and this port had to be told the second.
|
|
481
|
+
*
|
|
482
|
+
* Zero or less was clamped to one second at first, because a clamp fails
|
|
483
|
+
* closed. That is not enough: a clamp is a value quietly becoming a DIFFERENT
|
|
484
|
+
* value, and this repository has already shipped a configuration that silently
|
|
485
|
+
* became a different configuration and stayed green throughout.
|
|
486
|
+
*
|
|
487
|
+
* A positive FRACTION was then still truncated -- `90.4` became `90` -- which is
|
|
488
|
+
* the same argument one scale down, and "truncation lands in the safe
|
|
489
|
+
* direction" is the clamping argument restated. It could never have been
|
|
490
|
+
* honoured as written either, since `claimed_until` is an integer timestamp in
|
|
491
|
+
* all three languages, so a fractional lease was always going to become a
|
|
492
|
+
* different lease.
|
|
493
|
+
*
|
|
494
|
+
* ## Every door, not just the typed one
|
|
495
|
+
*
|
|
496
|
+
* `Number.isInteger` is doing more work here than it looks, and the parameter is
|
|
497
|
+
* `unknown` on purpose. The type annotation on `claim()` stops nothing at
|
|
498
|
+
* runtime: a lease read out of a JSON config arrives as the STRING `'90.4'`,
|
|
499
|
+
* and `Math.trunc('90.4')` is `90` -- so the guard would have been defeated
|
|
500
|
+
* from inside the file that declares the setting, silently, exactly as it was
|
|
501
|
+
* in the reference. `Number.isInteger` is false for a float, for `NaN`, for
|
|
502
|
+
* either infinity, and for anything that is not a number at all, and it does
|
|
503
|
+
* not coerce.
|
|
504
|
+
*
|
|
505
|
+
* `NaN` deserves its own mention: `NaN <= 0` is FALSE, so a bare positivity
|
|
506
|
+
* check passes it straight through to become a `claimed_until` of `NaN`.
|
|
507
|
+
*/
|
|
508
|
+
function assertLease(seconds) {
|
|
509
|
+
if (typeof seconds !== 'number' || !Number.isInteger(seconds) || seconds <= 0) {
|
|
510
|
+
throw HarnessError.taskLeaseInvalid(seconds);
|
|
511
|
+
}
|
|
512
|
+
return seconds;
|
|
513
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { JsonObject } from '../json.js';
|
|
2
|
+
import type { Durability, SessionStore } from './session-store.js';
|
|
3
|
+
/**
|
|
4
|
+
* State on disk, as one JSON file per key. DURABLE, and it says so.
|
|
5
|
+
*
|
|
6
|
+
* The default durable driver for a port with no database and no dependencies.
|
|
7
|
+
* A real deployment points the durable slot at a database instead; this exists
|
|
8
|
+
* so the package WORKS ON INSTALL rather than requiring infrastructure before
|
|
9
|
+
* the first session can be opened — the same reason the PHP reference defaults
|
|
10
|
+
* both slots to `database`.
|
|
11
|
+
*
|
|
12
|
+
* ## Two properties that matter more than speed
|
|
13
|
+
*
|
|
14
|
+
* **Writes are atomic.** A payload is written to a temporary file and renamed
|
|
15
|
+
* over the target, because `rename` is atomic on both POSIX and Windows. A
|
|
16
|
+
* partial write here is a corrupted thread, and a process killed mid-write is
|
|
17
|
+
* ordinary rather than exotic.
|
|
18
|
+
*
|
|
19
|
+
* **The lock is cross-process.** It is an exclusive-create lockfile (`wx`),
|
|
20
|
+
* which is the one primitive that is atomic on every filesystem worth
|
|
21
|
+
* supporting — unlike an "is it there?" check followed by a create, which has a
|
|
22
|
+
* window between the two. Two workers on one machine genuinely exclude each
|
|
23
|
+
* other. Two workers on different machines sharing a network filesystem do NOT
|
|
24
|
+
* reliably, and no file lock can promise that; use a database or Redis there.
|
|
25
|
+
*/
|
|
26
|
+
export declare class FileSessionStore implements SessionStore {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(directory: string);
|
|
29
|
+
durability(): Durability;
|
|
30
|
+
get(key: string): Promise<JsonObject | null>;
|
|
31
|
+
put(key: string, payload: JsonObject, ttlSeconds?: number | null): Promise<void>;
|
|
32
|
+
forget(key: string): Promise<void>;
|
|
33
|
+
withLock<T>(key: string, callback: () => T | Promise<T>, ttlSeconds?: number, waitSeconds?: number): Promise<T>;
|
|
34
|
+
}
|