bantamkit-mcp 0.35.7 → 0.35.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyargparse.js +48 -2
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
package/dist/shiftwork.d.ts
CHANGED
|
@@ -18,6 +18,22 @@ export declare const HISTORY_RING_SIZE = 5;
|
|
|
18
18
|
* thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
|
|
19
19
|
*/
|
|
20
20
|
export declare const BRIEF_EVENT = "brief";
|
|
21
|
+
/**
|
|
22
|
+
* job65/#162: the `event` value of the line `clockOut` appends right after an accounting line
|
|
23
|
+
* whose COMMIT failed. Log-then-commit keeps the accounting; this marker says the line above
|
|
24
|
+
* it did not count, so `briefed` hands the brief back and the documented retry is accepted.
|
|
25
|
+
* `UNCOMMITTED_EVENT` in `shiftwork.py`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const UNCOMMITTED_EVENT = "uncommitted";
|
|
28
|
+
/**
|
|
29
|
+
* job65/#117: the keys the runtime writes on every accounting line. A caller line that sets
|
|
30
|
+
* one is refused before anything is written — `record.set` over the base keys used to let
|
|
31
|
+
* `{"unit": "C", "event": "brief"}` turn A's clock-out into a forged brief of C that `briefed`
|
|
32
|
+
* then trusted. `briefed` is NOT here on purpose: F6 ruled a self-reported `briefed` is
|
|
33
|
+
* OVERWRITTEN by the measured value, pinned on both sides. `RESERVED_LEDGER_KEYS` in
|
|
34
|
+
* `shiftwork.py`, same order, because the sentence names them in this order.
|
|
35
|
+
*/
|
|
36
|
+
export declare const RESERVED_LEDGER_KEYS: readonly string[];
|
|
21
37
|
/**
|
|
22
38
|
* `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))` — UTC, SECOND precision.
|
|
23
39
|
*
|
|
@@ -30,17 +46,40 @@ export declare function timestamp(now: number): string;
|
|
|
30
46
|
export interface ClockOptions {
|
|
31
47
|
/** Seconds since the epoch, as `time.time()` returns them. */
|
|
32
48
|
now?: number;
|
|
49
|
+
/**
|
|
50
|
+
* A TEST SEAM, like `now`: how long `clockOut` waits for `<checkpoint>.lock` before it
|
|
51
|
+
* refuses. The product value is `LOCK_WAIT_MS`; the Python side has no such option and its
|
|
52
|
+
* tests monkeypatch the module constant instead. Nothing on the wire reads it.
|
|
53
|
+
*/
|
|
54
|
+
lockWaitMs?: number;
|
|
33
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* job65/#116: the checkpoint's read-modify-write runs under `<checkpoint>.lock`, taken with
|
|
58
|
+
* O_EXCL — the one primitive both platforms and both runtimes share without a dependency,
|
|
59
|
+
* and the shape `driver.lock` and the hook ledger's lock (J65-4, #167) already use, so a
|
|
60
|
+
* Python writer and a Node writer serialise against each other on the one file they share.
|
|
61
|
+
* One policy differs from the ledger's: a writer that cannot take the lock within
|
|
62
|
+
* `LOCK_WAIT_MS` is REFUSED, nothing written — never let through unlocked, because through
|
|
63
|
+
* is exactly the race #116 measured. A lock older than `LOCK_STALE_MS` is a dead holder's.
|
|
64
|
+
* The three numbers are `shiftwork.py`'s, verbatim.
|
|
65
|
+
*/
|
|
66
|
+
export declare const LOCK_WAIT_MS = 5000;
|
|
67
|
+
export declare const LOCK_STALE_MS = 30000;
|
|
68
|
+
export declare const LOCK_POLL_MS = 5;
|
|
34
69
|
/**
|
|
35
70
|
* Validate the checkpoint and return a unit's brief, or a refusal.
|
|
36
71
|
*
|
|
37
|
-
* `unitId` OMITTED
|
|
38
|
-
* briefed
|
|
39
|
-
* is taken LAST,
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
72
|
+
* `unitId` OMITTED SELECTS THE CURSOR UNIT (J60/D1), given, that unit — and EITHER WAY the
|
|
73
|
+
* selected unit is briefed only IF the batch view says it is ready (job65/#164). The readiness
|
|
74
|
+
* judgement is taken LAST, after the three above it, so no existing refusal moves. Until job65
|
|
75
|
+
* the default path skipped it — "byte for byte what it was" — and #164 measured what that
|
|
76
|
+
* exemption cost: a cursor left on a `done` unit by a hand edit, or on a unit whose
|
|
77
|
+
* dependency had not run by a re-plan, was briefed with no refusal and no warning, the exact
|
|
78
|
+
* hazard job60 closed for the named path. A unit that is not ready, including an id naming no
|
|
79
|
+
* unit at all and a terminal cursor unit (the batch view drops done/dropped units), is ONE
|
|
80
|
+
* refusal carrying the list the caller's next move is to pick from; a batch view that refuses
|
|
81
|
+
* (cycle, unknown dependency) passes through verbatim on both paths, exactly as `planBatches`
|
|
82
|
+
* already passes `readValid`'s.
|
|
44
83
|
*
|
|
45
84
|
* `ready` cannot be empty here: the all-terminal test above has already answered `success`
|
|
46
85
|
* for a plan with no non-terminal unit, and a non-terminal unit that cannot batch is a
|
package/dist/shiftwork.js
CHANGED
|
@@ -47,10 +47,18 @@
|
|
|
47
47
|
* same place. That makes three refusal strings, not two, and the third is likewise a
|
|
48
48
|
* per-side literal.
|
|
49
49
|
*
|
|
50
|
-
* WHAT IS NOT PORTED, DELIBERATELY.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
50
|
+
* WHAT IS NOT PORTED, DELIBERATELY. Log-then-commit ordering is preserved exactly: the
|
|
51
|
+
* accounting line is appended BEFORE the atomic rename, so a partial failure can lose the
|
|
52
|
+
* commit but never the accounting — and since job65 (#162) the orphan line is followed by an
|
|
53
|
+
* `{"event": "uncommitted"}` marker so `briefed` hands the brief back and the retry lands;
|
|
54
|
+
* since job65 (#117) a caller line that sets one of `RESERVED_LEDGER_KEYS` is refused before
|
|
55
|
+
* anything is written; and since job65 (#164) `clockIn()` with no `unitId` applies the same
|
|
56
|
+
* readiness test as `clockIn(unitId)`. "There is no lock" used to open this paragraph, with the
|
|
57
|
+
* reason that the MCP topology has one orchestrator by construction; job60's waves made that
|
|
58
|
+
* false and job65 (#116) measured it — two clock-outs of DIFFERENT units at one instant lost
|
|
59
|
+
* 10 of 20 `ok` answers in 10 trials, on each runtime and across them. Both runtimes now take
|
|
60
|
+
* `<checkpoint>.lock` around the whole read-modify-write (see `acquireLock`), the same
|
|
61
|
+
* O_EXCL protocol on both sides because the two share one checkpoint on disk.
|
|
54
62
|
*
|
|
55
63
|
* `depends_on` USED TO BE LISTED ABOVE, AND NO LONGER BELONGS THERE. `planBatches` below
|
|
56
64
|
* READS `depends_on` and answers the batch view, so the module no longer ignores the field;
|
|
@@ -74,7 +82,9 @@
|
|
|
74
82
|
*/
|
|
75
83
|
import { AssetNotFound, loadSchema, loadToolAsset } from './assets.js';
|
|
76
84
|
import { schemaError } from './contract.js';
|
|
77
|
-
import {
|
|
85
|
+
import { randomBytes } from 'node:crypto';
|
|
86
|
+
import { closeSync, openSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
87
|
+
import { asPyOSError, pyAppendText, pyJoin, pyNewlineOut, PyOSError, pyReplace, pyResolve, pyUnlink, pyReadText, PyUnicodeDecodeError, } from './memory/pyfs.js';
|
|
78
88
|
import { dumpJson, fromJs, parseJson, PyJSONDecodeError, reprValue } from './pyjson.js';
|
|
79
89
|
import { checkSchema, PyJsonSchemaUnsupported } from './pyjsonschema.js';
|
|
80
90
|
import { plan } from './workplan.js';
|
|
@@ -100,6 +110,22 @@ export const HISTORY_RING_SIZE = 5;
|
|
|
100
110
|
* thing it recorded with the same word the wire used. `BRIEF_EVENT` in `shiftwork.py`.
|
|
101
111
|
*/
|
|
102
112
|
export const BRIEF_EVENT = 'brief';
|
|
113
|
+
/**
|
|
114
|
+
* job65/#162: the `event` value of the line `clockOut` appends right after an accounting line
|
|
115
|
+
* whose COMMIT failed. Log-then-commit keeps the accounting; this marker says the line above
|
|
116
|
+
* it did not count, so `briefed` hands the brief back and the documented retry is accepted.
|
|
117
|
+
* `UNCOMMITTED_EVENT` in `shiftwork.py`.
|
|
118
|
+
*/
|
|
119
|
+
export const UNCOMMITTED_EVENT = 'uncommitted';
|
|
120
|
+
/**
|
|
121
|
+
* job65/#117: the keys the runtime writes on every accounting line. A caller line that sets
|
|
122
|
+
* one is refused before anything is written — `record.set` over the base keys used to let
|
|
123
|
+
* `{"unit": "C", "event": "brief"}` turn A's clock-out into a forged brief of C that `briefed`
|
|
124
|
+
* then trusted. `briefed` is NOT here on purpose: F6 ruled a self-reported `briefed` is
|
|
125
|
+
* OVERWRITTEN by the measured value, pinned on both sides. `RESERVED_LEDGER_KEYS` in
|
|
126
|
+
* `shiftwork.py`, same order, because the sentence names them in this order.
|
|
127
|
+
*/
|
|
128
|
+
export const RESERVED_LEDGER_KEYS = ['unit', 'role', 'status', 'ts', 'event'];
|
|
103
129
|
// ------------------------------------------------------------------- tagged-value sugar
|
|
104
130
|
const str = (v) => ({ t: 'str', v });
|
|
105
131
|
const int = (v) => ({ t: 'int', v: BigInt(v) });
|
|
@@ -161,8 +187,13 @@ function readValid(path) {
|
|
|
161
187
|
raw = pyReadText(path);
|
|
162
188
|
}
|
|
163
189
|
catch (e) {
|
|
164
|
-
|
|
190
|
+
// #163: `UnicodeDecodeError` is a ValueError, not an OSError, and it escaped every tool as
|
|
191
|
+
// a raised error while a missing file answered `result: error`. Same sentence head as the
|
|
192
|
+
// missing file: the bytes could not be read. The paragraph above that says it escapes in
|
|
193
|
+
// both runtimes is the history; it no longer does in either.
|
|
194
|
+
if (e instanceof PyOSError || e instanceof PyUnicodeDecodeError) {
|
|
165
195
|
return { document: null, refusal: errorResult(`checkpoint unreadable: ${e.message}`) };
|
|
196
|
+
}
|
|
166
197
|
throw e;
|
|
167
198
|
}
|
|
168
199
|
let document;
|
|
@@ -178,6 +209,18 @@ function readValid(path) {
|
|
|
178
209
|
const problem = schemaError(raw, loadSchema(SCHEMA_NAME));
|
|
179
210
|
if (problem !== null)
|
|
180
211
|
return { document: null, refusal: errorResult(`checkpoint invalid: ${problem}`) };
|
|
212
|
+
// #165: the schema cannot say that unit ids are unique, so this is the only check, and it
|
|
213
|
+
// sits on the read every tool starts with: status, clock_in and clock_out now answer what
|
|
214
|
+
// `shiftwork_plan` already answered through the core. Before it, `findUnit` took the first
|
|
215
|
+
// match and a clock-out of the first `A` of two left a document no job could finish. The
|
|
216
|
+
// sentence is `workplan`'s, verbatim, so the plan view's answer does not move.
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
for (const unit of subList(subDict(document, 'plan'), 'units').v) {
|
|
219
|
+
const id = text(field(unit, 'id'));
|
|
220
|
+
if (seen.has(id))
|
|
221
|
+
return { document: null, refusal: errorResult(`duplicate node id ${id}`) };
|
|
222
|
+
seen.add(id);
|
|
223
|
+
}
|
|
181
224
|
return { document: document, refusal: null };
|
|
182
225
|
}
|
|
183
226
|
function findUnit(document, unitId) {
|
|
@@ -201,6 +244,19 @@ export function timestamp(now) {
|
|
|
201
244
|
return (`${pad(when.getUTCFullYear(), 4)}-${pad(when.getUTCMonth() + 1)}-${pad(when.getUTCDate())}` +
|
|
202
245
|
`T${pad(when.getUTCHours())}:${pad(when.getUTCMinutes())}:${pad(when.getUTCSeconds())}Z`);
|
|
203
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* job65/#116: the checkpoint's read-modify-write runs under `<checkpoint>.lock`, taken with
|
|
249
|
+
* O_EXCL — the one primitive both platforms and both runtimes share without a dependency,
|
|
250
|
+
* and the shape `driver.lock` and the hook ledger's lock (J65-4, #167) already use, so a
|
|
251
|
+
* Python writer and a Node writer serialise against each other on the one file they share.
|
|
252
|
+
* One policy differs from the ledger's: a writer that cannot take the lock within
|
|
253
|
+
* `LOCK_WAIT_MS` is REFUSED, nothing written — never let through unlocked, because through
|
|
254
|
+
* is exactly the race #116 measured. A lock older than `LOCK_STALE_MS` is a dead holder's.
|
|
255
|
+
* The three numbers are `shiftwork.py`'s, verbatim.
|
|
256
|
+
*/
|
|
257
|
+
export const LOCK_WAIT_MS = 5000;
|
|
258
|
+
export const LOCK_STALE_MS = 30_000;
|
|
259
|
+
export const LOCK_POLL_MS = 5;
|
|
204
260
|
// ------------------------------------------------------------------------ F6: the ledger
|
|
205
261
|
/**
|
|
206
262
|
* `Path(str(path) + ".log.jsonl")` — string concatenation on the NORMALIZED path, not
|
|
@@ -226,8 +282,16 @@ function logPath(path) {
|
|
|
226
282
|
* two write legs of `clockOut` already use, for the same reason.
|
|
227
283
|
*/
|
|
228
284
|
function recordBrief(log, unitId, role, now) {
|
|
285
|
+
recordEvent(log, BRIEF_EVENT, unitId, role, now);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* `_record_event`: one `{event, ts, unit, role}` line, best effort — the brief line (F6) and,
|
|
289
|
+
* since job65/#162, the `uncommitted` marker share this shape, so a reader tells every event
|
|
290
|
+
* line from an accounting line by the one key, `event`, never by `status`.
|
|
291
|
+
*/
|
|
292
|
+
function recordEvent(log, event, unitId, role, now) {
|
|
229
293
|
const record = new Map([
|
|
230
|
-
['event', str(
|
|
294
|
+
['event', str(event)],
|
|
231
295
|
['ts', str(timestamp(now))],
|
|
232
296
|
['unit', str(unitId)],
|
|
233
297
|
['role', role],
|
|
@@ -250,11 +314,16 @@ const SPLITLINES = /\r\n|[\n\r\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029]/;
|
|
|
250
314
|
* `_briefed`: was a brief issued for `unitId` since its last clock-out? Read off the ledger.
|
|
251
315
|
*
|
|
252
316
|
* Walk the ledger in order, tracking only lines that name this unit: a brief line sets the
|
|
253
|
-
* flag, an accounting line (any line that is not a brief event) clears it
|
|
317
|
+
* flag, an accounting line (any line that is not a brief event) clears it, and — since
|
|
318
|
+
* job65/#162 — an `uncommitted` line restores whatever the flag was before the accounting
|
|
319
|
+
* line it follows, because that line's commit failed and it did not count. The answer is the
|
|
254
320
|
* flag at the end — so a clock-out CONSUMES the brief: brief, blocked, re-run without a
|
|
255
|
-
* clock_in reads `false`; brief, blocked, clock_in, done reads `true
|
|
256
|
-
*
|
|
257
|
-
*
|
|
321
|
+
* clock_in reads `false`; brief, blocked, clock_in, done reads `true`; brief, uncommitted
|
|
322
|
+
* clock-out, retry reads `true`. Until job65 the retry read `false` by choice ("detectable
|
|
323
|
+
* false negatives beat undetectable false positives"); #162 measured that the orphan line also
|
|
324
|
+
* SPENT the brief, so a briefed non-cursor unit's retry was refused and the documented
|
|
325
|
+
* recovery did not exist. The inline re-run is still `false`: the marker is what now tells the
|
|
326
|
+
* two apart.
|
|
258
327
|
*
|
|
259
328
|
* Reads the ledger the way a reader must: a missing or unreadable log is `false`, a line
|
|
260
329
|
* that is not JSON or not an object is skipped, never fatal. `record.get("unit") != unit_id`
|
|
@@ -275,6 +344,7 @@ function briefed(log, unitId) {
|
|
|
275
344
|
return false;
|
|
276
345
|
}
|
|
277
346
|
let flag = false;
|
|
347
|
+
let beforeLastAccounting = false;
|
|
278
348
|
for (const line of content.split(SPLITLINES)) {
|
|
279
349
|
let record;
|
|
280
350
|
try {
|
|
@@ -291,7 +361,15 @@ function briefed(log, unitId) {
|
|
|
291
361
|
if (unit === undefined || unit.t !== 'str' || unit.v !== unitId)
|
|
292
362
|
continue;
|
|
293
363
|
const event = record.v.get('event');
|
|
294
|
-
|
|
364
|
+
const name = event !== undefined && event.t === 'str' ? event.v : null;
|
|
365
|
+
if (name === BRIEF_EVENT)
|
|
366
|
+
flag = true;
|
|
367
|
+
else if (name === UNCOMMITTED_EVENT)
|
|
368
|
+
flag = beforeLastAccounting;
|
|
369
|
+
else {
|
|
370
|
+
beforeLastAccounting = flag;
|
|
371
|
+
flag = false;
|
|
372
|
+
}
|
|
295
373
|
}
|
|
296
374
|
return flag;
|
|
297
375
|
}
|
|
@@ -353,13 +431,17 @@ function readyIds(view) {
|
|
|
353
431
|
/**
|
|
354
432
|
* Validate the checkpoint and return a unit's brief, or a refusal.
|
|
355
433
|
*
|
|
356
|
-
* `unitId` OMITTED
|
|
357
|
-
* briefed
|
|
358
|
-
* is taken LAST,
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
434
|
+
* `unitId` OMITTED SELECTS THE CURSOR UNIT (J60/D1), given, that unit — and EITHER WAY the
|
|
435
|
+
* selected unit is briefed only IF the batch view says it is ready (job65/#164). The readiness
|
|
436
|
+
* judgement is taken LAST, after the three above it, so no existing refusal moves. Until job65
|
|
437
|
+
* the default path skipped it — "byte for byte what it was" — and #164 measured what that
|
|
438
|
+
* exemption cost: a cursor left on a `done` unit by a hand edit, or on a unit whose
|
|
439
|
+
* dependency had not run by a re-plan, was briefed with no refusal and no warning, the exact
|
|
440
|
+
* hazard job60 closed for the named path. A unit that is not ready, including an id naming no
|
|
441
|
+
* unit at all and a terminal cursor unit (the batch view drops done/dropped units), is ONE
|
|
442
|
+
* refusal carrying the list the caller's next move is to pick from; a batch view that refuses
|
|
443
|
+
* (cycle, unknown dependency) passes through verbatim on both paths, exactly as `planBatches`
|
|
444
|
+
* already passes `readValid`'s.
|
|
363
445
|
*
|
|
364
446
|
* `ready` cannot be empty here: the all-terminal test above has already answered `success`
|
|
365
447
|
* for a plan with no non-terminal unit, and a non-terminal unit that cannot batch is a
|
|
@@ -403,28 +485,27 @@ export function clockIn(checkpoint, unitId = null, options = {}) {
|
|
|
403
485
|
]);
|
|
404
486
|
}
|
|
405
487
|
const cursor = text(field(subDict(doc, 'plan'), 'cursor'));
|
|
406
|
-
// D1: selection,
|
|
407
|
-
// the
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if (unitId !== null && unitId !== undefined) {
|
|
412
|
-
const view = batchView(doc);
|
|
413
|
-
if ('result' in view)
|
|
414
|
-
return errorResult(view.reason);
|
|
415
|
-
const ready = readyIds(view);
|
|
416
|
-
// `", "` in BATCH order, not sorted: the order the caller would dispatch them in.
|
|
417
|
-
if (!ready.includes(unitId))
|
|
418
|
-
return errorResult(`unit ${unitId} is not ready; ready is ${ready.join(', ')}`);
|
|
419
|
-
selected = unitId;
|
|
420
|
-
}
|
|
488
|
+
// D1: selection, then ONE readiness test for both paths (#164). The dangling-cursor
|
|
489
|
+
// escalation stays above the graph and keeps its sentence; on the named path a `unitId`
|
|
490
|
+
// naming no unit falls through to the not-ready sentence, as before.
|
|
491
|
+
const named = unitId !== null && unitId !== undefined;
|
|
492
|
+
const selected = named ? unitId : cursor;
|
|
421
493
|
const unit = findUnit(doc, selected);
|
|
422
|
-
if (unit === null) {
|
|
494
|
+
if (!named && unit === null) {
|
|
423
495
|
return dict([
|
|
424
496
|
['result', str('escalate')],
|
|
425
497
|
['reason', str(`cursor ${cursor} names no unit`)],
|
|
426
498
|
]);
|
|
427
499
|
}
|
|
500
|
+
const view = batchView(doc);
|
|
501
|
+
if ('result' in view)
|
|
502
|
+
return errorResult(view.reason);
|
|
503
|
+
const ready = readyIds(view);
|
|
504
|
+
// `", "` in BATCH order, not sorted: the order the caller would dispatch them in. A
|
|
505
|
+
// terminal cursor unit is not in `ready`, so it meets this same sentence.
|
|
506
|
+
if (unit === null || !ready.includes(selected)) {
|
|
507
|
+
return errorResult(`unit ${selected} is not ready; ready is ${ready.join(', ')}`);
|
|
508
|
+
}
|
|
428
509
|
// F6: the brief is issued, so say so in the ledger — best effort, and only on this branch:
|
|
429
510
|
// a refusal above issued nothing and therefore records nothing. `pyJoin` is the same
|
|
430
511
|
// normalisation `clockOut` applies before it builds ITS log path, so the two land in one file.
|
|
@@ -662,6 +743,25 @@ function accountingRefusal(unitId, unit, accounting) {
|
|
|
662
743
|
return null;
|
|
663
744
|
return `unit ${unitId} in role ${text(field(unit, 'role'))} reported an accounting line the schema refuses: ${problem}`;
|
|
664
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* `_reserved_refusal` (#117): an accounting line that sets a key the runtime writes is refused,
|
|
748
|
+
* nothing written. `record.set` over the base keys let `{"unit": "C", "event": "brief"}` on A's
|
|
749
|
+
* clock-out write a line `briefed` read as a brief of C, and the next `clockOut("C")` was
|
|
750
|
+
* accepted although C was never briefed, was not the cursor, and had a dependency still `todo`
|
|
751
|
+
* (measured: A=done B=todo C=done). A refusal rather than a merge-under, because a line whose
|
|
752
|
+
* keys are dropped is a line the orchestrator believes it wrote. Runs after the shape check
|
|
753
|
+
* (`additionalProperties: true` lets these keys through it) and before any mutation. The keys
|
|
754
|
+
* are named in `RESERVED_LEDGER_KEYS` order so both runtimes spell one sentence for one line.
|
|
755
|
+
*/
|
|
756
|
+
function reservedRefusal(unitId, unit, accounting) {
|
|
757
|
+
if (accounting === null || accounting === undefined)
|
|
758
|
+
return null;
|
|
759
|
+
const line = asPatch(accounting);
|
|
760
|
+
const present = RESERVED_LEDGER_KEYS.filter((key) => line.has(key));
|
|
761
|
+
if (present.length === 0)
|
|
762
|
+
return null;
|
|
763
|
+
return `unit ${unitId} in role ${text(field(unit, 'role'))} reported an accounting line that sets the runtime's own keys: ${present.join(', ')}`;
|
|
764
|
+
}
|
|
665
765
|
/**
|
|
666
766
|
* Apply the cursor unit's result, validate the WHOLE mutated document, write atomically.
|
|
667
767
|
*
|
|
@@ -698,6 +798,96 @@ function accountingRefusal(unitId, unit, accounting) {
|
|
|
698
798
|
*/
|
|
699
799
|
export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry, accounting = null, options = {}) {
|
|
700
800
|
const path = pyJoin(checkpoint);
|
|
801
|
+
const real = realTarget(path);
|
|
802
|
+
const lock = `${real}.lock`;
|
|
803
|
+
const { held, refusal: locked } = acquireLock(lock, options.lockWaitMs ?? LOCK_WAIT_MS);
|
|
804
|
+
if (locked !== null)
|
|
805
|
+
return locked;
|
|
806
|
+
try {
|
|
807
|
+
return clockOutLocked(path, real, unitId, status, handoffPatch, historyEntry, accounting, options);
|
|
808
|
+
}
|
|
809
|
+
finally {
|
|
810
|
+
if (held) {
|
|
811
|
+
try {
|
|
812
|
+
unlinkSync(lock);
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
/* `contextlib.suppress(OSError)`: the release's own failure is not the story */
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* #137: the file the rename lands on — a symlinked checkpoint's TARGET, so the link stays a
|
|
822
|
+
* link and every other path to the same file sees the clock-out. Only the write side
|
|
823
|
+
* resolves: the read, the ledger and every sentence use the path as given.
|
|
824
|
+
*/
|
|
825
|
+
function realTarget(path) {
|
|
826
|
+
try {
|
|
827
|
+
return pyResolve(path);
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
return path; // a symlink loop, or a path the OS will not resolve — `Path.resolve`'s own arm
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* `_acquire_lock`: take `<checkpoint>.lock` with O_EXCL. Answers `{held, refusal}`.
|
|
835
|
+
*
|
|
836
|
+
* `held` is false without a refusal in exactly one case: the directory will not hold a lock
|
|
837
|
+
* file for a reason other than one existing (read-only, gone). The clock-out then goes on
|
|
838
|
+
* UNLOCKED, because the temp file that follows cannot be created there either and ITS
|
|
839
|
+
* refusal is the one that names the cause — the `readonly-directory-*` arms keep their
|
|
840
|
+
* sentences. A lock that exists is waited on; one older than `LOCK_STALE_MS` is a dead
|
|
841
|
+
* holder's and is removed; past the wait the clock-out is refused with nothing written, so
|
|
842
|
+
* the caller can retry — never let through, which is the race.
|
|
843
|
+
*/
|
|
844
|
+
function acquireLock(lock, waitMs) {
|
|
845
|
+
const deadline = Date.now() + waitMs;
|
|
846
|
+
for (;;) {
|
|
847
|
+
let fd;
|
|
848
|
+
try {
|
|
849
|
+
fd = openSync(lock, 'wx', 0o644);
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
if (e?.code !== 'EEXIST')
|
|
853
|
+
return { held: false, refusal: null };
|
|
854
|
+
try {
|
|
855
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS)
|
|
856
|
+
unlinkSync(lock);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
/* released between the open and the stat: go round again */
|
|
860
|
+
}
|
|
861
|
+
if (Date.now() >= deadline) {
|
|
862
|
+
return { held: false, refusal: errorResult(`checkpoint locked by another clock-out, nothing written: ${lock}`) };
|
|
863
|
+
}
|
|
864
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
writeSync(fd, `${process.pid}\n`);
|
|
869
|
+
}
|
|
870
|
+
finally {
|
|
871
|
+
closeSync(fd);
|
|
872
|
+
}
|
|
873
|
+
return { held: true, refusal: null };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/** `tmp.open("x", encoding="utf-8").write(text)` — O_EXCL, newline translation included. */
|
|
877
|
+
function pyWriteTextExclusive(path, text) {
|
|
878
|
+
try {
|
|
879
|
+
writeFileSync(path, pyNewlineOut(text), { encoding: 'utf8', flag: 'wx' });
|
|
880
|
+
}
|
|
881
|
+
catch (e) {
|
|
882
|
+
throw asPyOSError(e, path, undefined, 'crt');
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
/** `str(OSError(e.errno, e.strerror, str(path)))`: the sentence with the checkpoint in the filename slot. */
|
|
886
|
+
function atCheckpoint(e, path) {
|
|
887
|
+
return new PyOSError(e.errno, e.code, e.strerror, path, null, e.winerror).message;
|
|
888
|
+
}
|
|
889
|
+
/** `clock_out`'s body, run under the lock its caller holds: mutate, validate, write. */
|
|
890
|
+
function clockOutLocked(path, real, unitId, status, handoffPatch, historyEntry, accounting, options) {
|
|
701
891
|
const { document, refusal } = readValid(path);
|
|
702
892
|
if (refusal !== null)
|
|
703
893
|
return refusal;
|
|
@@ -722,6 +912,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
|
|
|
722
912
|
const badLine = accountingRefusal(unitId, unit, accounting);
|
|
723
913
|
if (badLine !== null)
|
|
724
914
|
return errorResult(badLine);
|
|
915
|
+
const forged = reservedRefusal(unitId, unit, accounting);
|
|
916
|
+
if (forged !== null)
|
|
917
|
+
return errorResult(forged);
|
|
725
918
|
unit.v.set('status', str(status));
|
|
726
919
|
// D3: the cursor follows the GRAPH, on the MUTATED document — the unit just finished is
|
|
727
920
|
// already out of it. Three steps, and the middle one is only reachable for a graph that
|
|
@@ -747,9 +940,9 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
|
|
|
747
940
|
const problem = schemaError(dumpJson(doc), loadSchema(SCHEMA_NAME));
|
|
748
941
|
if (problem !== null)
|
|
749
942
|
return errorResult(`refused to write: ${problem}`);
|
|
750
|
-
// `record.update(accounting or {})`:
|
|
751
|
-
//
|
|
752
|
-
// reaches the file anyway.
|
|
943
|
+
// `record.update(accounting or {})`: since #117 none of the four base keys (nor `event`)
|
|
944
|
+
// can be in the accounting — `reservedRefusal` above — and `sort_keys=True` means the
|
|
945
|
+
// merge order never reaches the file anyway.
|
|
753
946
|
const record = new Map([
|
|
754
947
|
['ts', str(timestamp(options.now ?? Date.now() / 1000))],
|
|
755
948
|
['unit', str(unitId)],
|
|
@@ -771,10 +964,17 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
|
|
|
771
964
|
throw e;
|
|
772
965
|
return errorResult(`accounting log unwritable, checkpoint untouched: ${e.message}`);
|
|
773
966
|
}
|
|
774
|
-
|
|
967
|
+
// #116: a PER-WRITER temp name, created exclusively — never `with_suffix(".tmp")`, which
|
|
968
|
+
// gave every writer of one checkpoint the same temp file (two renames of one file, so one
|
|
969
|
+
// `ok` answer with nothing of it on disk) and gave a suffix-less `checkpoint` the name of
|
|
970
|
+
// an unrelated sibling, `checkpoint.tmp`, which it overwrote and renamed away. #137: the
|
|
971
|
+
// rename lands on `real`, the link's target. The refusal names the checkpoint AS GIVEN and
|
|
972
|
+
// not the temp name: a random name in a sentence is not reproducible, and the file the
|
|
973
|
+
// caller could not write is, to the caller, the checkpoint.
|
|
974
|
+
const tmp = `${real}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
775
975
|
try {
|
|
776
|
-
|
|
777
|
-
pyReplace(tmp,
|
|
976
|
+
pyWriteTextExclusive(tmp, `${dumpJson(doc, { indent: 2 })}\n`);
|
|
977
|
+
pyReplace(tmp, real);
|
|
778
978
|
}
|
|
779
979
|
catch (e) {
|
|
780
980
|
if (!(e instanceof PyOSError))
|
|
@@ -787,7 +987,11 @@ export function clockOut(checkpoint, unitId, status, handoffPatch, historyEntry,
|
|
|
787
987
|
if (!(cleanup instanceof PyOSError))
|
|
788
988
|
throw cleanup;
|
|
789
989
|
}
|
|
790
|
-
|
|
990
|
+
// #162: the accounting line above is kept (log-then-commit: never lose the accounting)
|
|
991
|
+
// and MARKED: this line says it did not count, so `briefed` hands the brief back and the
|
|
992
|
+
// retry the sentence invites is accepted. Best effort, like the brief line.
|
|
993
|
+
recordEvent(log, UNCOMMITTED_EVENT, unitId, field(unit, 'role'), options.now ?? Date.now() / 1000);
|
|
994
|
+
return errorResult(`checkpoint unwritable, last log line uncommitted: ${atCheckpoint(e, path)}`);
|
|
791
995
|
}
|
|
792
996
|
return dict([
|
|
793
997
|
['result', str('ok')],
|
package/dist/tokenledger.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* total`), never as a comparison afterwards: a `number` past 2**53 is already rounded, so the
|
|
32
32
|
* check would be reading a value this side had got wrong.
|
|
33
33
|
*/
|
|
34
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
34
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
36
|
import { BantamError } from './errors.js';
|
|
37
37
|
import { MAX_SAFE_INT, TOKEN_CLASSES, loadPriceTable, priceTokens, } from './pricing.js';
|
|
@@ -125,8 +125,21 @@ export function walk(base) {
|
|
|
125
125
|
// exactly as it is there.
|
|
126
126
|
isDirectory = false;
|
|
127
127
|
}
|
|
128
|
-
if (isDirectory)
|
|
129
|
-
|
|
128
|
+
if (isDirectory) {
|
|
129
|
+
// #151: a symlinked directory is not a transcript tree. `statSync` follows the link,
|
|
130
|
+
// so a link to a directory lands in this arm and is skipped here: neither walked nor
|
|
131
|
+
// read. Following it walked a link to an ancestor until ELOOP and counted the one
|
|
132
|
+
// transcript beneath it once per level. `skill_audit`'s rule, and the reference's.
|
|
133
|
+
let link = false;
|
|
134
|
+
try {
|
|
135
|
+
link = lstatSync(child).isSymbolicLink();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
link = false;
|
|
139
|
+
}
|
|
140
|
+
if (!link)
|
|
141
|
+
visit(child, `${rel}/`);
|
|
142
|
+
}
|
|
130
143
|
else if (name.endsWith(TRANSCRIPT_SUFFIX))
|
|
131
144
|
found.push([child, rel]);
|
|
132
145
|
}
|
|
@@ -139,7 +152,10 @@ export function walk(base) {
|
|
|
139
152
|
* raises, which would make an unreadable transcript a counted one on this side alone.
|
|
140
153
|
*/
|
|
141
154
|
function readUtf8Strict(path) {
|
|
142
|
-
|
|
155
|
+
// `ignoreBOM: true` KEEPS a leading U+FEFF (#150). The default strips it from the first line
|
|
156
|
+
// only, where `read_text(encoding="utf-8")` keeps it and `json.loads` refuses it; kept, the
|
|
157
|
+
// line is `unparsed-line` on both sides, as a BOM on any later line already was.
|
|
158
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
143
159
|
return decoder.decode(readFileSync(path));
|
|
144
160
|
}
|
|
145
161
|
function usageOf(record) {
|
package/dist/updatecheck.d.ts
CHANGED
|
@@ -30,6 +30,18 @@ export declare const UPDATE_NEVER = "update: never checked.";
|
|
|
30
30
|
* falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
|
|
31
31
|
*/
|
|
32
32
|
export declare const UPDATE_AVAILABLE: string;
|
|
33
|
+
/**
|
|
34
|
+
* The command `UPDATE_AVAILABLE` tells the operator to run: THIS node, absolute (job66/#171).
|
|
35
|
+
*
|
|
36
|
+
* WAS: `` `{program} --update` ``, the bare command, which `--install` puts only in
|
|
37
|
+
* `<prefix>/node_modules/.bin` — on nobody's PATH — so the sentence answered `command not
|
|
38
|
+
* found` from a GUI-launched host's shell. `process.execPath` and this build's own `cli.js`
|
|
39
|
+
* (the sibling of this module in `dist/`), each double-quoted, need no PATH and hold a home
|
|
40
|
+
* directory with a space in it. The reference fills in `sys.executable -m bantamkit.mcpserver`
|
|
41
|
+
* instead; the TEMPLATE stays byte-identical and the filling is the `docs/porting.md` row
|
|
42
|
+
* *the update command names each runtime's own interpreter*.
|
|
43
|
+
*/
|
|
44
|
+
export declare function updateCommand(): string;
|
|
33
45
|
/** Names the date because the record may be months old. */
|
|
34
46
|
export declare const UPDATE_CURRENT = "update: {program} {installed} is current as of {date}.";
|
|
35
47
|
/**
|
|
@@ -111,8 +123,8 @@ export declare function readRecord(path?: string): Record<string, unknown> | nul
|
|
|
111
123
|
* and not only on the `current` path: a record that cannot say when it was written is not a
|
|
112
124
|
* record, and one rule is one thing for the two runtimes to reproduce instead of two.
|
|
113
125
|
*/
|
|
114
|
-
export declare function decide(installed: string, source: Source, record: Record<string, unknown> | null, key?: string): UpdateStatus;
|
|
126
|
+
export declare function decide(installed: string, source: Source, record: Record<string, unknown> | null, key?: string, command?: string): UpdateStatus;
|
|
115
127
|
/** Read the record and decide. The whole reader, in one call, for `bantamkit_status`. */
|
|
116
|
-
export declare function updateStatus(installed: string, key?: string, path?: string): UpdateStatus;
|
|
128
|
+
export declare function updateStatus(installed: string, key?: string, path?: string, command?: string): UpdateStatus;
|
|
117
129
|
/** Exactly one of the five sentences. Always a line: there is no silent state here. */
|
|
118
|
-
export declare function updateLine(installed: string, key?: string, path?: string): string;
|
|
130
|
+
export declare function updateLine(installed: string, key?: string, path?: string, command?: string): string;
|
package/dist/updatecheck.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* `0.10.0` is the pair that says so, and `test/updatecheck.test.mjs` both runs it and pins
|
|
36
36
|
* structurally that nothing comparator-shaped is defined here.
|
|
37
37
|
*
|
|
38
|
-
* NO NEW DEPENDENCY: `node:fs`, `node:os` and `node:
|
|
38
|
+
* NO NEW DEPENDENCY: `node:fs`, `node:os`, `node:path` and `node:url`. The `str.format` substitution the
|
|
39
39
|
* reference gets from CPython is the four lines of `fill` below — `selfupdate.ts` has the same
|
|
40
40
|
* four and cannot be imported for them, for the reason two paragraphs up.
|
|
41
41
|
*
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
import { readFileSync } from 'node:fs';
|
|
46
46
|
import { homedir } from 'node:os';
|
|
47
47
|
import { join } from 'node:path';
|
|
48
|
+
import { fileURLToPath } from 'node:url';
|
|
48
49
|
import { compareVersions } from './npminstall.js';
|
|
49
50
|
export { compareVersions };
|
|
50
51
|
/**
|
|
@@ -80,7 +81,21 @@ export const UPDATE_NEVER = 'update: never checked.';
|
|
|
80
81
|
* falsify the claim, the same reason `selfupdate.COMPARISON` carries both.
|
|
81
82
|
*/
|
|
82
83
|
export const UPDATE_AVAILABLE = 'update: {program} {installed} is running; the package index has {latest} — run ' +
|
|
83
|
-
'`{
|
|
84
|
+
'`{command}`, then reconnect the host.';
|
|
85
|
+
/**
|
|
86
|
+
* The command `UPDATE_AVAILABLE` tells the operator to run: THIS node, absolute (job66/#171).
|
|
87
|
+
*
|
|
88
|
+
* WAS: `` `{program} --update` ``, the bare command, which `--install` puts only in
|
|
89
|
+
* `<prefix>/node_modules/.bin` — on nobody's PATH — so the sentence answered `command not
|
|
90
|
+
* found` from a GUI-launched host's shell. `process.execPath` and this build's own `cli.js`
|
|
91
|
+
* (the sibling of this module in `dist/`), each double-quoted, need no PATH and hold a home
|
|
92
|
+
* directory with a space in it. The reference fills in `sys.executable -m bantamkit.mcpserver`
|
|
93
|
+
* instead; the TEMPLATE stays byte-identical and the filling is the `docs/porting.md` row
|
|
94
|
+
* *the update command names each runtime's own interpreter*.
|
|
95
|
+
*/
|
|
96
|
+
export function updateCommand() {
|
|
97
|
+
return `"${process.execPath}" "${fileURLToPath(new URL('./cli.js', import.meta.url))}" --update`;
|
|
98
|
+
}
|
|
84
99
|
/** Names the date because the record may be months old. */
|
|
85
100
|
export const UPDATE_CURRENT = 'update: {program} {installed} is current as of {date}.';
|
|
86
101
|
/**
|
|
@@ -268,7 +283,7 @@ function checkedDate(record, key) {
|
|
|
268
283
|
* and not only on the `current` path: a record that cannot say when it was written is not a
|
|
269
284
|
* record, and one rule is one thing for the two runtimes to reproduce instead of two.
|
|
270
285
|
*/
|
|
271
|
-
export function decide(installed, source, record, key = KEY) {
|
|
286
|
+
export function decide(installed, source, record, key = KEY, command = updateCommand()) {
|
|
272
287
|
if (source === SOURCE_ABSENT)
|
|
273
288
|
return { state: STATE_NEVER, line: UPDATE_NEVER };
|
|
274
289
|
if (source !== SOURCE_RECORD || record === null) {
|
|
@@ -283,7 +298,7 @@ export function decide(installed, source, record, key = KEY) {
|
|
|
283
298
|
if (order < 0) {
|
|
284
299
|
return {
|
|
285
300
|
state: STATE_AVAILABLE,
|
|
286
|
-
line: fill(UPDATE_AVAILABLE, { program: PROGRAM, installed, latest }),
|
|
301
|
+
line: fill(UPDATE_AVAILABLE, { program: PROGRAM, installed, latest, command }),
|
|
287
302
|
};
|
|
288
303
|
}
|
|
289
304
|
if (order === 0) {
|
|
@@ -298,11 +313,11 @@ export function decide(installed, source, record, key = KEY) {
|
|
|
298
313
|
};
|
|
299
314
|
}
|
|
300
315
|
/** Read the record and decide. The whole reader, in one call, for `bantamkit_status`. */
|
|
301
|
-
export function updateStatus(installed, key = KEY, path = recordPath()) {
|
|
316
|
+
export function updateStatus(installed, key = KEY, path = recordPath(), command = updateCommand()) {
|
|
302
317
|
const { source, record } = loadRecord(path);
|
|
303
|
-
return decide(installed, source, record, key);
|
|
318
|
+
return decide(installed, source, record, key, command);
|
|
304
319
|
}
|
|
305
320
|
/** Exactly one of the five sentences. Always a line: there is no silent state here. */
|
|
306
|
-
export function updateLine(installed, key = KEY, path = recordPath()) {
|
|
307
|
-
return updateStatus(installed, key, path).line;
|
|
321
|
+
export function updateLine(installed, key = KEY, path = recordPath(), command = updateCommand()) {
|
|
322
|
+
return updateStatus(installed, key, path, command).line;
|
|
308
323
|
}
|