@ngockhoale/ukit 2.7.7 → 2.7.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/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/src/context/detectProjectContext.js +5 -0
- package/src/core/codeintel/invalidation.js +4 -0
- package/src/core/diffPlan.js +60 -1
- package/src/core/fileOps.js +46 -119
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/agents/bug-debugger.md +1 -1
- package/templates/.claude/agents/feature-implementer.md +2 -2
- package/templates/.claude/commands/ukit/handoff-clear.md +11 -0
- package/templates/.claude/commands/ukit/handoff-create.md +1 -1
- package/templates/.claude/commands/ukit/handoff-fullstack.md +26 -1
- package/templates/.claude/commands/ukit/handoff-implement.md +1 -1
- package/templates/.claude/commands/ukit/handoff-review.md +1 -1
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/handoff-model-guard.sh +22 -11
- package/templates/.claude/hooks/reset-compact-pressure.sh +10 -0
- package/templates/.claude/hooks/skill-router.sh +15 -8
- package/templates/.claude/hooks/verification-guard.sh +3 -0
- package/templates/.claude/ukit/index/route-task.mjs +237 -32
- package/templates/.claude/ukit/index/stale-spec-check.mjs +38 -3
- package/templates/.claude/ukit/runtime/async-lock.mjs +240 -42
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +5 -2
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +217 -17
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +38 -4
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +131 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +35 -20
- package/templates/.claude/ukit/runtime/token-utils.mjs +37 -126
- package/templates/.codex/settings.json +1 -5
- package/templates/.omp/agents/bug-debugger.md +1 -1
- package/templates/.omp/agents/feature-implementer.md +2 -2
- package/templates/.omp/hooks/pre/ukit-bridge.js +216 -51
- package/templates/docs/AI_HANDOFF/INDEX.md +1 -1
- package/templates/docs/AI_HANDOFF/RULES.md +6 -6
- package/templates/ukit/storage/config.json +2 -2
|
@@ -294,7 +294,8 @@ export async function readRouteState(projectRoot, payload = {}) {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
export async function readExecutionLedger(projectRoot, payload = {}) {
|
|
297
|
-
|
|
297
|
+
const target = ledgerPath(projectRoot, payload);
|
|
298
|
+
return mergeContinuationSidecar(await readJson(target, null), target);
|
|
298
299
|
}
|
|
299
300
|
|
|
300
301
|
function promptKeyFromText(promptText) {
|
|
@@ -313,7 +314,7 @@ function hasUnfinishedCompletion(state = {}, ledger = {}) {
|
|
|
313
314
|
if (!IMPLEMENT_MODES.has(mode)) return false;
|
|
314
315
|
const required = requiredEvidence(state);
|
|
315
316
|
if (required.length === 0) return false;
|
|
316
|
-
return required.some((item) => !evidenceSatisfied(item, ledger, state
|
|
317
|
+
return required.some((item) => !evidenceSatisfied(item, ledger, state));
|
|
317
318
|
}
|
|
318
319
|
|
|
319
320
|
function resumeSessionHash(sessionId) {
|
|
@@ -400,9 +401,38 @@ function explicitError(payload = {}) {
|
|
|
400
401
|
payload.tool_result?.is_error,
|
|
401
402
|
payload.tool_response?.isError,
|
|
402
403
|
payload.tool_response?.is_error,
|
|
404
|
+
// omp tool details carry their own error flag (eval cells, bash timeouts).
|
|
405
|
+
payload.details?.isError,
|
|
406
|
+
payload.tool_output?.details?.isError,
|
|
407
|
+
payload.tool_result?.details?.isError,
|
|
403
408
|
].some((value) => value === true);
|
|
404
409
|
}
|
|
405
410
|
|
|
411
|
+
// omp tool results do not all spell the exit code the same way: bash details
|
|
412
|
+
// carry `exitCode` (non-zero only), other producers serialize `exit_code`, and
|
|
413
|
+
// eval results report per-cell codes under `details.cells[].exitCode`. A missed
|
|
414
|
+
// shape used to journal a failed verification as exitCode null → success, so
|
|
415
|
+
// evidence never satisfied and the Stop gate bounced to the cap.
|
|
416
|
+
function detailsExitCode(details) {
|
|
417
|
+
if (!details || typeof details !== 'object') return null;
|
|
418
|
+
for (const value of [details.exitCode, details.exit_code]) {
|
|
419
|
+
// Number(null) === 0 — a JSON `"exitCode": null` must fall through to the
|
|
420
|
+
// next probe (exit_code, then cells[]), never journal as a successful exit.
|
|
421
|
+
if (value === undefined || value === null) continue;
|
|
422
|
+
const number = Number(value);
|
|
423
|
+
if (Number.isFinite(number)) return number;
|
|
424
|
+
}
|
|
425
|
+
const cells = Array.isArray(details.cells) ? details.cells : [];
|
|
426
|
+
let lastCode = null;
|
|
427
|
+
for (const cell of cells) {
|
|
428
|
+
const number = Number(cell?.exitCode ?? cell?.exit_code);
|
|
429
|
+
if (!Number.isFinite(number)) continue;
|
|
430
|
+
if (number !== 0) return number;
|
|
431
|
+
lastCode = number;
|
|
432
|
+
}
|
|
433
|
+
return lastCode;
|
|
434
|
+
}
|
|
435
|
+
|
|
406
436
|
function extractExitCode(payload = {}) {
|
|
407
437
|
const candidates = [
|
|
408
438
|
payload.tool_output?.exitCode,
|
|
@@ -415,8 +445,20 @@ function extractExitCode(payload = {}) {
|
|
|
415
445
|
payload.tool_response?.exit_code,
|
|
416
446
|
payload.exitCode,
|
|
417
447
|
payload.exit_code,
|
|
448
|
+
// omp details shapes: the bridge mirrors event.details under BOTH
|
|
449
|
+
// tool_output.details and tool_result.details, and eval results nest the
|
|
450
|
+
// real code under details.cells[].
|
|
451
|
+
detailsExitCode(payload.tool_output?.details),
|
|
452
|
+
detailsExitCode(payload.tool_result?.details),
|
|
453
|
+
detailsExitCode(payload.details),
|
|
454
|
+
detailsExitCode(payload.tool_response?.details),
|
|
455
|
+
detailsExitCode(payload.tool_output),
|
|
456
|
+
detailsExitCode(payload.tool_result),
|
|
418
457
|
];
|
|
419
458
|
for (const value of candidates) {
|
|
459
|
+
// Number(null) === 0 — a JSON `"exitCode": null` (or a details probe that
|
|
460
|
+
// found nothing) must read as "no code", never as a successful exit.
|
|
461
|
+
if (value === undefined || value === null) continue;
|
|
420
462
|
const number = Number(value);
|
|
421
463
|
if (Number.isFinite(number)) return number;
|
|
422
464
|
}
|
|
@@ -801,6 +843,96 @@ function journalQuarantinePathFor(target) {
|
|
|
801
843
|
return `${journalPathFor(target)}.quarantine`;
|
|
802
844
|
}
|
|
803
845
|
|
|
846
|
+
// F-4: the continuation counter is STATE, not a journal row. When the journal is
|
|
847
|
+
// full (or its lock unavailable) a continuation/notified event used to be rejected
|
|
848
|
+
// outright, so MAX_CONTINUATIONS fired several bounces late — or never while the
|
|
849
|
+
// ledger lock stayed contended. Rejected counter events land in this small sidecar
|
|
850
|
+
// instead; readExecutionLedger merges it into the view every evaluator sees, and
|
|
851
|
+
// the next committed ledger write folds it in and removes it.
|
|
852
|
+
function continuationSidecarPathFor(target) {
|
|
853
|
+
return `${target}.continuations.json`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Overlay sidecar counter state onto the ledger when the sidecar is newer than the
|
|
857
|
+
// ledger's last continuation. A sidecar at or behind the ledger is already folded
|
|
858
|
+
// (or written for a superseded request) and is ignored — never double-counted.
|
|
859
|
+
// When the main file is absent the sidecar IS the counter state: returning null
|
|
860
|
+
// hid every tick from evaluators and the next committed write deleted the sidecar
|
|
861
|
+
// unread, so the cap fired late. A sidecar without counter state still yields null
|
|
862
|
+
// — the no-ledger contract is preserved.
|
|
863
|
+
async function mergeContinuationSidecar(ledger, target) {
|
|
864
|
+
let sidecar = null;
|
|
865
|
+
try {
|
|
866
|
+
sidecar = JSON.parse(await fs.readFile(continuationSidecarPathFor(target), 'utf8'));
|
|
867
|
+
} catch {
|
|
868
|
+
return ledger;
|
|
869
|
+
}
|
|
870
|
+
if (!sidecar || typeof sidecar !== 'object') return ledger;
|
|
871
|
+
const sidecarAt = Number(sidecar.lastContinuationAt) || 0;
|
|
872
|
+
if (!ledger) {
|
|
873
|
+
const hasState = sidecarAt > 0
|
|
874
|
+
|| Number(sidecar.continuationCount) > 0
|
|
875
|
+
|| sidecar.notified === true;
|
|
876
|
+
if (!hasState) return null;
|
|
877
|
+
return {
|
|
878
|
+
requestKey: sidecar.requestKey ?? null,
|
|
879
|
+
promptKey: sidecar.promptKey ?? null,
|
|
880
|
+
continuationRequestKey: sidecar.requestKey ?? null,
|
|
881
|
+
continuationCount: Number(sidecar.continuationCount) || 0,
|
|
882
|
+
noProgressCount: Number(sidecar.noProgressCount) || 0,
|
|
883
|
+
lastProgressDigest: sidecar.lastProgressDigest ?? null,
|
|
884
|
+
notified: sidecar.notified === true,
|
|
885
|
+
lastContinuationAt: sidecarAt,
|
|
886
|
+
updatedAt: Number(sidecar.updatedAt) || sidecarAt,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
const ledgerAt = Number(ledger.lastContinuationAt) || 0;
|
|
890
|
+
if (!(sidecarAt > ledgerAt)) return ledger;
|
|
891
|
+
return {
|
|
892
|
+
...ledger,
|
|
893
|
+
continuationCount: Number(sidecar.continuationCount) || 0,
|
|
894
|
+
noProgressCount: Number(sidecar.noProgressCount) || 0,
|
|
895
|
+
continuationRequestKey: sidecar.requestKey ?? ledger.continuationRequestKey ?? null,
|
|
896
|
+
lastProgressDigest: sidecar.lastProgressDigest ?? ledger.lastProgressDigest ?? null,
|
|
897
|
+
notified: ledger.notified === true || sidecar.notified === true,
|
|
898
|
+
lastContinuationAt: sidecarAt,
|
|
899
|
+
updatedAt: Math.max(Number(ledger.updatedAt) || 0, sidecarAt),
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Best-effort counter tick for a continuation/notified event the journal rejected.
|
|
904
|
+
// Reads the merged ledger view (main file + existing sidecar) so consecutive ticks
|
|
905
|
+
// accumulate even while the main lock stays busy. Returns { ok } — a sidecar write
|
|
906
|
+
// failure means the tick is genuinely lost and the caller reports the rejection.
|
|
907
|
+
async function bumpContinuationSidecar(target, event) {
|
|
908
|
+
try {
|
|
909
|
+
const base = (await mergeContinuationSidecar(await readJson(target, null), target)) || {};
|
|
910
|
+
const applied = event.type === 'notified'
|
|
911
|
+
? { ...base, notified: true, updatedAt: Date.now() }
|
|
912
|
+
: applyContinuationToLedger(base, event);
|
|
913
|
+
// The merge gate requires sidecarAt > ledgerAt. A notified tick inherits the
|
|
914
|
+
// base stamp unchanged, so it must be bumped past the base it was computed
|
|
915
|
+
// from — otherwise the tick is invisible to every evaluator and the cap's
|
|
916
|
+
// final notice loops forever while contention persists. The +1 floor also
|
|
917
|
+
// covers a continuation landing in the same millisecond as the base stamp.
|
|
918
|
+
const baseAt = Number(base.lastContinuationAt) || 0;
|
|
919
|
+
const stampedAt = Math.max(Number(applied.lastContinuationAt) || 0, baseAt + 1);
|
|
920
|
+
await writeJsonAtomic(continuationSidecarPathFor(target), {
|
|
921
|
+
requestKey: event?.requestKey ?? applied.requestKey ?? applied.continuationRequestKey ?? null,
|
|
922
|
+
promptKey: event?.promptKey ?? applied.promptKey ?? null,
|
|
923
|
+
continuationCount: Number(applied.continuationCount) || 0,
|
|
924
|
+
noProgressCount: Number(applied.noProgressCount) || 0,
|
|
925
|
+
lastProgressDigest: applied.lastProgressDigest ?? null,
|
|
926
|
+
notified: applied.notified === true,
|
|
927
|
+
lastContinuationAt: stampedAt,
|
|
928
|
+
updatedAt: Date.now(),
|
|
929
|
+
});
|
|
930
|
+
return { ok: true };
|
|
931
|
+
} catch {
|
|
932
|
+
return { ok: false };
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
804
936
|
function newEventId() {
|
|
805
937
|
return `${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
|
|
806
938
|
}
|
|
@@ -1214,6 +1346,11 @@ export async function recordLedgerEvent(event, {
|
|
|
1214
1346
|
// Consume the journal only after the ledger write landed: a crash before this line
|
|
1215
1347
|
// leaves the journal intact and the next drain re-applies idempotently by eventId.
|
|
1216
1348
|
if (drained.commit) await drained.commit();
|
|
1349
|
+
// The continuation sidecar was already merged into `current` (and therefore into
|
|
1350
|
+
// the ledger just written): its ticks are now durable state, so the sidecar is
|
|
1351
|
+
// retired. A tick landing between the read and this rm is lost — same accepted
|
|
1352
|
+
// window as the journal drain — but the breaker still advances.
|
|
1353
|
+
try { await fs.rm(continuationSidecarPathFor(target), { force: true }); } catch {}
|
|
1217
1354
|
return { committed: true, eventId, value, drained: drained.applied, quarantined: drained.quarantined };
|
|
1218
1355
|
});
|
|
1219
1356
|
if (outcome.ok) {
|
|
@@ -1228,9 +1365,16 @@ export async function recordLedgerEvent(event, {
|
|
|
1228
1365
|
// NEVER mutated unlocked — journal the event once; the next acquired lock reconciles it.
|
|
1229
1366
|
const record = buildJournalRecord({ event, eventId, payload, routeState: routeState || null });
|
|
1230
1367
|
const journalResult = await appendJournalRecord(target, record, { signal, deadlineMs });
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1368
|
+
if (journalResult.ok) return { journaled: true, eventId };
|
|
1369
|
+
// F-4: continuation/notified are counter state, not journal rows — a journal-full
|
|
1370
|
+
// (or journal-unavailable) rejection must still advance the counter or
|
|
1371
|
+
// MAX_CONTINUATIONS fires late/never. Land the tick in the sidecar the evaluator
|
|
1372
|
+
// merges; only a sidecar write failure is a real rejection.
|
|
1373
|
+
if (event.type === 'continuation' || event.type === 'notified') {
|
|
1374
|
+
const sidecar = await bumpContinuationSidecar(target, event);
|
|
1375
|
+
if (sidecar.ok) return { counted: true, eventId, reason: journalResult.reason };
|
|
1376
|
+
}
|
|
1377
|
+
return { rejected: true, eventId, reason: journalResult.reason };
|
|
1234
1378
|
}
|
|
1235
1379
|
|
|
1236
1380
|
// TASK-027: the receipt is classified (kind, file, command, targeted/broad scope) from the
|
|
@@ -1291,6 +1435,44 @@ export async function recordExecutionReceipt({
|
|
|
1291
1435
|
);
|
|
1292
1436
|
}
|
|
1293
1437
|
|
|
1438
|
+
// F-15: mirror of isExplicitBroadVerificationRequest in
|
|
1439
|
+
// templates/.claude/hooks/verification-guard.sh — the guard uses this prompt signal to
|
|
1440
|
+
// let a broad suite run under a targeted policy, so the completion gate must honor the
|
|
1441
|
+
// same signal when it classifies the resulting broad receipt. Keep both lists in sync.
|
|
1442
|
+
function isExplicitBroadVerificationRequest(promptText) {
|
|
1443
|
+
const text = String(promptText || '').toLowerCase();
|
|
1444
|
+
return [
|
|
1445
|
+
/run (the )?full test suite/,
|
|
1446
|
+
/run all tests/,
|
|
1447
|
+
/full verification/,
|
|
1448
|
+
/verify everything/,
|
|
1449
|
+
/run the whole suite/,
|
|
1450
|
+
/run broad verification/,
|
|
1451
|
+
/full suite/,
|
|
1452
|
+
/chạy (?:toàn bộ|full) test/,
|
|
1453
|
+
/chạy hết test/,
|
|
1454
|
+
/chạy full test suite/,
|
|
1455
|
+
/verify toàn bộ/,
|
|
1456
|
+
/kiểm tra toàn bộ/,
|
|
1457
|
+
// Plan-driven execution prompts often authorize the plan's final broad
|
|
1458
|
+
// verification without spelling it as "full test suite".
|
|
1459
|
+
/(?:run|execute|implement|do)\b.*\bfull\b.*\b(?:prd\s+)?plan\b/,
|
|
1460
|
+
/\bfull\b.*\b(?:prd\s+)?plan\b.*\b(?:verify|verification|test|tests)\b/,
|
|
1461
|
+
/(?:chạy|lam|làm|thực hiện|triển khai)\b.*\bfull\b.*\b(?:prd|plan|ke hoach|kế hoạch)\b/,
|
|
1462
|
+
/\bfull\b.*\b(?:prd|plan|ke hoach|kế hoạch)\b.*\b(?:verify|verification|test|tests|kiểm tra)\b/,
|
|
1463
|
+
].some((pattern) => pattern.test(text));
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// The route cache strips prompt text (route-task.mjs *ForCache deletes it), so the live
|
|
1467
|
+
// state is the only place the explicit-broad signal survives for the evaluator.
|
|
1468
|
+
function explicitBroadVerificationRequested(state = {}) {
|
|
1469
|
+
const routingContext = state?.routingContext || {};
|
|
1470
|
+
return [
|
|
1471
|
+
routingContext.lastExplicitUserPromptText,
|
|
1472
|
+
routingContext.promptText,
|
|
1473
|
+
].some((promptText) => isExplicitBroadVerificationRequest(promptText));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1294
1476
|
function requiredEvidence(state = {}) {
|
|
1295
1477
|
const routeSummary = state?.routeSummary || {};
|
|
1296
1478
|
const contractEvidence = routeSummary.executionContract?.completionEvidence;
|
|
@@ -1300,14 +1482,19 @@ function requiredEvidence(state = {}) {
|
|
|
1300
1482
|
return [...new Set(routeSummary.completionState?.missingEvidence || [])];
|
|
1301
1483
|
}
|
|
1302
1484
|
|
|
1303
|
-
function evidenceSatisfied(evidence, ledger = {},
|
|
1485
|
+
function evidenceSatisfied(evidence, ledger = {}, state = {}) {
|
|
1486
|
+
const routeSummary = state?.routeSummary || {};
|
|
1304
1487
|
if (evidence === 'write-evidence') return ledger.writeSucceeded === true;
|
|
1305
1488
|
if (evidence === 'verification-evidence') {
|
|
1306
1489
|
// WS-C: when the route names concrete verification commands, only a receipt that ran
|
|
1307
1490
|
// one of them counts — an unrelated `yarn test` no longer satisfies the gate.
|
|
1491
|
+
// F-15: unless the user explicitly requested/approved a broad suite — the same
|
|
1492
|
+
// prompt signal verification-guard.sh honors — in which case a passing
|
|
1493
|
+
// scope='broad' receipt (verificationSucceeded) satisfies the gate too.
|
|
1308
1494
|
const routedCommands = routedVerificationCommands(routeSummary);
|
|
1309
1495
|
if (routedCommands.length > 0) {
|
|
1310
|
-
return ledger.targetedVerificationSucceeded === true
|
|
1496
|
+
return ledger.targetedVerificationSucceeded === true
|
|
1497
|
+
|| (explicitBroadVerificationRequested(state) && ledger.verificationSucceeded === true);
|
|
1311
1498
|
}
|
|
1312
1499
|
return ledger.verificationSucceeded === true;
|
|
1313
1500
|
}
|
|
@@ -1433,7 +1620,7 @@ export function evaluateCompletion({ state = {}, ledger = {} } = {}) {
|
|
|
1433
1620
|
|| ledger.requestKey === state.requestKey
|
|
1434
1621
|
|| (ledger.promptKey && evidencePromptKey(state) === ledger.promptKey);
|
|
1435
1622
|
const effectiveLedger = sameRequest ? ledger : {};
|
|
1436
|
-
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger,
|
|
1623
|
+
const missingEvidence = evidence.filter((item) => !evidenceSatisfied(item, effectiveLedger, state));
|
|
1437
1624
|
if (missingEvidence.length === 0) {
|
|
1438
1625
|
// Silent success: the route is present and every required evidence is satisfied. Marked
|
|
1439
1626
|
// `complete` so the CLI dispatch recognizes it BEFORE the loud final else — otherwise a
|
|
@@ -1609,12 +1796,19 @@ async function readStdin() {
|
|
|
1609
1796
|
|
|
1610
1797
|
// Malformed hook input crashes the evaluate BEFORE any route/session can be read, so the only
|
|
1611
1798
|
// stable identity is the project root. Count consecutive crashes there; past MAX_GATE_CRASHES,
|
|
1612
|
-
// release LOUD naming the crash and the remedy instead of blocking a corrupt hook forever.
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1799
|
+
// release LOUD naming the crash and the remedy instead of blocking a corrupt hook forever. A
|
|
1800
|
+
// successful evaluate resets the count.
|
|
1801
|
+
//
|
|
1802
|
+
// F-3: the counter exists to break the infinite bounce — so losing the count must
|
|
1803
|
+
// never BECOME the infinite bounce. When the counter write fails, fail OPEN (loud
|
|
1804
|
+
// release) instead of emitting an unconditional block that can never reach the cap.
|
|
1615
1805
|
async function handleEvaluateCrash(projectRoot, error) {
|
|
1616
1806
|
const detail = `malformed hook input crashed the completion gate (${error?.message || error})`;
|
|
1617
1807
|
const blockReason = () => `${detail}. This is the UKit completion gate, not the task — re-send the task in a new message so the hook payload is regenerated.`;
|
|
1808
|
+
const loudRelease = (message) => {
|
|
1809
|
+
process.stderr.write(`[ukit-completion] ${message}\n`);
|
|
1810
|
+
process.stdout.write(`${JSON.stringify({ systemMessage: message })}\n`);
|
|
1811
|
+
};
|
|
1618
1812
|
let count = 0;
|
|
1619
1813
|
try {
|
|
1620
1814
|
const current = await readJson(crashCounterPath(projectRoot), null);
|
|
@@ -1625,15 +1819,21 @@ async function handleEvaluateCrash(projectRoot, error) {
|
|
|
1625
1819
|
const next = count + 1;
|
|
1626
1820
|
try {
|
|
1627
1821
|
await writeJsonAtomic(crashCounterPath(projectRoot), { count: next, updatedAt: Date.now() });
|
|
1628
|
-
} catch {
|
|
1629
|
-
// Counter unwritable:
|
|
1630
|
-
|
|
1822
|
+
} catch (writeError) {
|
|
1823
|
+
// Counter unwritable: the crash loop can no longer be bounded, so blocking again
|
|
1824
|
+
// would bounce this stop forever. Release loudly — never silently.
|
|
1825
|
+
loudRelease(
|
|
1826
|
+
`UKit completion gate: ${detail}. The crash counter could not be persisted `
|
|
1827
|
+
+ `(${writeError?.message || writeError}), so the gate cannot bound repeated crashes and is `
|
|
1828
|
+
+ 'releasing loudly instead of blocking forever. Run: ukit install to refresh the runtime, '
|
|
1829
|
+
+ 'then re-send the task in a new message.',
|
|
1830
|
+
);
|
|
1631
1831
|
return;
|
|
1632
1832
|
}
|
|
1633
1833
|
if (next >= MAX_GATE_CRASHES) {
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1834
|
+
loudRelease(
|
|
1835
|
+
`UKit completion gate: ${detail}. This recurred ${next} times and cannot be bounded, so the stop is releasing loudly instead of blocking again. The hook payload is corrupt — run: ukit install to refresh the runtime, then re-send the task in a new message.`,
|
|
1836
|
+
);
|
|
1637
1837
|
return;
|
|
1638
1838
|
}
|
|
1639
1839
|
process.stdout.write(`${JSON.stringify({ decision: 'block', reason: blockReason() })}\n`);
|
|
@@ -437,6 +437,36 @@ async function run(payloadText, scriptArgs, { chainMarker = true, decisionShortC
|
|
|
437
437
|
return { results, elapsedMs, budgetMs: totalBudgetMs, budgetExhausted, skippedFailClosed };
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// TASK-006 (SPEC §S6, F-RC-2): a timed-out in-proc step abandons its
|
|
441
|
+
// Promise.race loser — when that loser holds ref'd libuv handles (timers,
|
|
442
|
+
// watchers, sockets) the event loop stays pinned and the host waits the full
|
|
443
|
+
// registered 30–73s timeout for a process that already produced its verdict.
|
|
444
|
+
// Every emit path ends here: flush stdout/stderr, then exit explicitly. The
|
|
445
|
+
// flush is bounded — a reader that never drains must not turn the fix into a
|
|
446
|
+
// new stall.
|
|
447
|
+
const FLUSH_EXIT_MS = Number(process.env.UKIT_HOOK_FLUSH_EXIT_MS || 2000);
|
|
448
|
+
|
|
449
|
+
function flushStream(stream) {
|
|
450
|
+
return new Promise((resolve) => {
|
|
451
|
+
try {
|
|
452
|
+
// end() flushes every queued chunk before invoking the callback —
|
|
453
|
+
// including when the stream was already ended/destroyed (the callback
|
|
454
|
+
// still fires, with an error we deliberately ignore).
|
|
455
|
+
stream.end(() => resolve());
|
|
456
|
+
} catch {
|
|
457
|
+
resolve();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function flushAndExit(code) {
|
|
463
|
+
await Promise.race([
|
|
464
|
+
Promise.all([flushStream(process.stdout), flushStream(process.stderr)]),
|
|
465
|
+
new Promise((resolve) => setTimeout(resolve, FLUSH_EXIT_MS).unref()),
|
|
466
|
+
]);
|
|
467
|
+
process.exit(code);
|
|
468
|
+
}
|
|
469
|
+
|
|
440
470
|
try {
|
|
441
471
|
let argv = process.argv.slice(2);
|
|
442
472
|
// TASK-234: `--emit-verdict` adapts the runner for Claude Code settings.json
|
|
@@ -493,16 +523,16 @@ try {
|
|
|
493
523
|
});
|
|
494
524
|
if (emitVerdict) {
|
|
495
525
|
process.stdout.write(deny);
|
|
496
|
-
process.exitCode = 0;
|
|
497
526
|
} else {
|
|
498
527
|
process.stdout.write(JSON.stringify({
|
|
499
528
|
results: [],
|
|
500
529
|
wrapperError: 'stdin staging truncated — fail-closed chain refused',
|
|
501
530
|
stdinTruncated: true,
|
|
502
531
|
}));
|
|
503
|
-
process.exitCode = 2;
|
|
504
532
|
}
|
|
505
|
-
process.exit
|
|
533
|
+
// TASK-006: flush before exit — a bare process.exit can truncate a
|
|
534
|
+
// verdict still queued on the pipe.
|
|
535
|
+
await flushAndExit(emitVerdict ? 0 : 2);
|
|
506
536
|
}
|
|
507
537
|
}
|
|
508
538
|
const chain = await run(payloadText, scriptPaths, {
|
|
@@ -595,10 +625,14 @@ try {
|
|
|
595
625
|
}
|
|
596
626
|
}
|
|
597
627
|
}
|
|
628
|
+
// TASK-006 (F-RC-2): the verdict is on the wire — exit now. Waiting for a
|
|
629
|
+
// natural exit lets an abandoned in-proc step's ref'd handles pin the loop
|
|
630
|
+
// until the host's registered timeout kills us.
|
|
631
|
+
await flushAndExit(process.exitCode ?? 0);
|
|
598
632
|
} catch (error) {
|
|
599
633
|
process.stdout.write(JSON.stringify({
|
|
600
634
|
results: [],
|
|
601
635
|
wrapperError: error?.message || String(error),
|
|
602
636
|
}));
|
|
603
|
-
|
|
637
|
+
await flushAndExit(1);
|
|
604
638
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Readers (hook-chain-runner.mjs) keep accepting the `@path` argv form unchanged.
|
|
17
17
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
|
+
import fsp from 'node:fs/promises';
|
|
19
20
|
import path from 'node:path';
|
|
20
21
|
import crypto from 'node:crypto';
|
|
21
22
|
|
|
@@ -41,6 +42,7 @@ function inlineReference(payloadText, bytes) {
|
|
|
41
42
|
path: null,
|
|
42
43
|
bytes,
|
|
43
44
|
cleanup() { /* nothing was staged — O(1) by construction */ },
|
|
45
|
+
async cleanupAsync() { /* nothing was staged — O(1) by construction */ },
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -90,6 +92,69 @@ export function createPayloadReference(text, {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
// createPayloadReferenceAsync(text, {maxBytes, deadlineMs, dir}) -> Promise<reference>
|
|
96
|
+
//
|
|
97
|
+
// TASK-008 (SPEC §S8, OMP-6): the sync variant's mkdirSync/writeFileSync/renameSync
|
|
98
|
+
// run deadline-free on the CALLER's event loop — on the omp host a stalled mount
|
|
99
|
+
// freezes the whole app, and the deadlineMs check at t≈0 is decorative because a
|
|
100
|
+
// stalled sync call never returns to re-check it. This variant does the same
|
|
101
|
+
// atomic staging through async fs raced against a real deadline; on expiry or
|
|
102
|
+
// any failure it degrades to inline transport exactly like the sync path.
|
|
103
|
+
// The losing fs promise is left to settle in the background — it is unref'd by
|
|
104
|
+
// the race and its result discarded; worst case it completes a tmp write that
|
|
105
|
+
// the sampled sweep later reclaims.
|
|
106
|
+
export async function createPayloadReferenceAsync(text, {
|
|
107
|
+
maxBytes = PAYLOAD_INLINE_MAX_BYTES,
|
|
108
|
+
deadlineMs = 250,
|
|
109
|
+
dir,
|
|
110
|
+
} = {}) {
|
|
111
|
+
const payloadText = String(text ?? '');
|
|
112
|
+
const bytes = Buffer.byteLength(payloadText, 'utf8');
|
|
113
|
+
if (bytes <= maxBytes) return inlineReference(payloadText, bytes);
|
|
114
|
+
if (!(deadlineMs > 0) || !dir) return inlineReference(payloadText, bytes);
|
|
115
|
+
|
|
116
|
+
const stage = (async () => {
|
|
117
|
+
await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
118
|
+
const name = `${Date.now().toString(36)}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.json`;
|
|
119
|
+
const finalPath = path.join(dir, name);
|
|
120
|
+
const tmpPath = path.join(dir, `.${name}.tmp`);
|
|
121
|
+
await fsp.writeFile(tmpPath, payloadText, { encoding: 'utf8', mode: 0o600 });
|
|
122
|
+
await fsp.rename(tmpPath, finalPath);
|
|
123
|
+
return {
|
|
124
|
+
mode: 'file',
|
|
125
|
+
arg: `@${finalPath}`,
|
|
126
|
+
text: payloadText,
|
|
127
|
+
path: finalPath,
|
|
128
|
+
bytes,
|
|
129
|
+
// TASK-002 (OMP-4): the bridge request path awaits cleanupAsync(); the
|
|
130
|
+
// sync-named cleanup() is kept for tests/CLI but must not run a blocking
|
|
131
|
+
// fs call on the omp host loop either — it delegates to the same async
|
|
132
|
+
// removal fire-and-forget.
|
|
133
|
+
cleanup() {
|
|
134
|
+
fsp.rm(finalPath, { force: true }).catch(() => { /* best effort */ });
|
|
135
|
+
},
|
|
136
|
+
async cleanupAsync() {
|
|
137
|
+
try { await fsp.rm(finalPath, { force: true }); } catch { /* best effort */ }
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
})();
|
|
141
|
+
// Fail-open contract (TASK-008 fix): the race must resolve to inline on ANY
|
|
142
|
+
// staging outcome that is not a completed file — deadline expiry AND fs
|
|
143
|
+
// rejection (ENOTDIR/EACCES/EROFS on a read-only or wedged mount). Racing the
|
|
144
|
+
// raw stage promise let a fast rejection propagate through runScriptChain and
|
|
145
|
+
// skip the whole hook chain; .catch(() => null) consumes it so the winner is
|
|
146
|
+
// always a reference or null. The same catch also swallows a rejection that
|
|
147
|
+
// lands after the deadline already won, so no unhandled rejection escapes.
|
|
148
|
+
let timer;
|
|
149
|
+
const deadline = new Promise((resolve) => {
|
|
150
|
+
timer = setTimeout(() => resolve(null), deadlineMs);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
});
|
|
153
|
+
const winner = await Promise.race([stage.catch(() => null), deadline]);
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
return winner ?? inlineReference(payloadText, bytes);
|
|
156
|
+
}
|
|
157
|
+
|
|
93
158
|
// probePayloadIntegrity(reference) -> null | 'missing' | 'partial'
|
|
94
159
|
//
|
|
95
160
|
// O(1): one stat of the file this bridge staged. Called by the bridge after the
|
|
@@ -106,6 +171,72 @@ export function probePayloadIntegrity(reference) {
|
|
|
106
171
|
}
|
|
107
172
|
}
|
|
108
173
|
|
|
174
|
+
// probePayloadIntegrityAsync(reference) -> Promise<null | 'missing' | 'partial'>
|
|
175
|
+
//
|
|
176
|
+
// TASK-002 (OMP-4): async twin of probePayloadIntegrity. The sync variant's
|
|
177
|
+
// statSync runs on the CALLER's event loop — on the omp host a stalled mount
|
|
178
|
+
// freezes the whole app (the socket-closed bug class). Identical verdicts.
|
|
179
|
+
export async function probePayloadIntegrityAsync(reference) {
|
|
180
|
+
if (!reference || reference.mode !== 'file' || !reference.path) return null;
|
|
181
|
+
try {
|
|
182
|
+
const stats = await fsp.stat(reference.path);
|
|
183
|
+
return stats.size === reference.bytes ? null : 'partial';
|
|
184
|
+
} catch {
|
|
185
|
+
return 'missing';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// sweepStalePayloadsAsync(dir, {now, maxAgeMs, maxEntries}) ->
|
|
190
|
+
// Promise<{sampled, scanned, removed}>
|
|
191
|
+
//
|
|
192
|
+
// TASK-002 (OMP-4): async twin of sweepStalePayloads — same bounded semantics
|
|
193
|
+
// through fs.promises so the omp host loop never blocks on directory work.
|
|
194
|
+
export async function sweepStalePayloadsAsync(dir, {
|
|
195
|
+
now = Date.now,
|
|
196
|
+
maxAgeMs = SWEEP_MAX_AGE_MS,
|
|
197
|
+
maxEntries = SWEEP_MAX_ENTRIES,
|
|
198
|
+
} = {}) {
|
|
199
|
+
let names;
|
|
200
|
+
try {
|
|
201
|
+
names = await fsp.readdir(dir);
|
|
202
|
+
} catch {
|
|
203
|
+
return { sampled: true, scanned: 0, removed: 0 };
|
|
204
|
+
}
|
|
205
|
+
const cutoff = now() - maxAgeMs;
|
|
206
|
+
let scanned = 0;
|
|
207
|
+
let removed = 0;
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
if (scanned >= maxEntries) break;
|
|
210
|
+
scanned += 1;
|
|
211
|
+
if (!name.endsWith('.json') && !name.endsWith('.tmp')) continue;
|
|
212
|
+
const filePath = path.join(dir, name);
|
|
213
|
+
try {
|
|
214
|
+
if ((await fsp.stat(filePath)).mtimeMs < cutoff) {
|
|
215
|
+
await fsp.rm(filePath, { force: true });
|
|
216
|
+
removed += 1;
|
|
217
|
+
}
|
|
218
|
+
} catch { /* raced away — fine */ }
|
|
219
|
+
}
|
|
220
|
+
return { sampled: true, scanned, removed };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// maybeSweepStalePayloadsAsync(dir, {probability, random, ...}) ->
|
|
224
|
+
// Promise<sweep report>
|
|
225
|
+
//
|
|
226
|
+
// TASK-002 (OMP-4): async twin of maybeSweepStalePayloads — identical sampled
|
|
227
|
+
// gate, delegating to sweepStalePayloadsAsync.
|
|
228
|
+
export async function maybeSweepStalePayloadsAsync(dir, {
|
|
229
|
+
probability,
|
|
230
|
+
random = Math.random,
|
|
231
|
+
now,
|
|
232
|
+
maxAgeMs,
|
|
233
|
+
maxEntries,
|
|
234
|
+
} = {}) {
|
|
235
|
+
const p = Number.isFinite(probability) ? Math.min(1, Math.max(0, probability)) : sweepProbabilityFromEnv();
|
|
236
|
+
if (random() >= p) return { sampled: false, scanned: 0, removed: 0 };
|
|
237
|
+
return sweepStalePayloadsAsync(dir, { now, maxAgeMs, maxEntries });
|
|
238
|
+
}
|
|
239
|
+
|
|
109
240
|
// sweepStalePayloads(dir, {now, maxAgeMs, maxEntries}) -> {sampled, scanned, removed}
|
|
110
241
|
//
|
|
111
242
|
// Bounded: processes at most maxEntries directory entries regardless of how many
|
|
@@ -168,7 +168,7 @@ export const DEDUPE_WINDOW_MS = 2000;
|
|
|
168
168
|
// Fixed budget slices inside the default 3000ms hook deadline: the ledger child
|
|
169
169
|
// gets the larger slice, each watchdog lock acquisition gets a short one.
|
|
170
170
|
const LEDGER_BUDGET_MS = 1500;
|
|
171
|
-
const LOCK_BUDGET_MS = 600;
|
|
171
|
+
export const LOCK_BUDGET_MS = 600;
|
|
172
172
|
|
|
173
173
|
const WATCHDOG_PROVENANCE_PREFIX = '[ukit-stop-coordinator] task-watchdog also requested a block: ';
|
|
174
174
|
const WATCHDOG_FAILURE_PREFIX = '[ukit-stop-coordinator] task-watchdog evaluator failed (advisory lane, reported verbatim): ';
|
|
@@ -453,28 +453,43 @@ function parseRunCursor(text) {
|
|
|
453
453
|
/**
|
|
454
454
|
* Mutate `.ukit/storage/cache/stop-coordinator/state.json`'s `handoff` slot:
|
|
455
455
|
* { signature, count }. Same signature as last time → count+1; a moved cursor
|
|
456
|
-
* resets the streak. Returns the committed streak count
|
|
457
|
-
*
|
|
456
|
+
* resets the streak. Returns the committed streak count.
|
|
457
|
+
*
|
|
458
|
+
* RC-4: a lock-busy acquisition used to return null, and the release branch
|
|
459
|
+
* required `streak !== null` — so a leaked state lock froze the
|
|
460
|
+
* stopGateMaxStalledBlocks breaker forever and the handoff-cursor lane became an
|
|
461
|
+
* unbounded bounce loop. The breaker must ALWAYS advance: on lock-busy (or any
|
|
462
|
+
* lock error) the same read-modify-write runs best-effort unlocked. A lost tick
|
|
463
|
+
* under a racing writer only delays the cap by one stop; a frozen breaker loops
|
|
464
|
+
* forever. Only a genuinely unwritable state file still returns null.
|
|
458
465
|
*/
|
|
459
466
|
async function bumpHandoffStallCount({ projectRoot, signature, lockBudgetMs }) {
|
|
460
467
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
468
|
+
const bump = async () => {
|
|
469
|
+
let current = {};
|
|
470
|
+
try {
|
|
471
|
+
current = JSON.parse(await fs.readFile(statePath, 'utf8')) || {};
|
|
472
|
+
} catch {}
|
|
473
|
+
const prev = current.handoff || {};
|
|
474
|
+
const count = prev.signature === signature ? (Number(prev.count) || 0) + 1 : 1;
|
|
475
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
476
|
+
await fs.writeFile(
|
|
477
|
+
statePath,
|
|
478
|
+
`${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
|
|
479
|
+
'utf8',
|
|
480
|
+
);
|
|
481
|
+
return count;
|
|
482
|
+
};
|
|
461
483
|
try {
|
|
462
|
-
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs },
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
statePath,
|
|
472
|
-
`${JSON.stringify({ ...current, handoff: { signature, count } }, null, 1)}\n`,
|
|
473
|
-
'utf8',
|
|
474
|
-
);
|
|
475
|
-
return count;
|
|
476
|
-
});
|
|
477
|
-
return outcome.ok ? outcome.value : null;
|
|
484
|
+
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, bump);
|
|
485
|
+
if (outcome.ok) return outcome.value;
|
|
486
|
+
} catch {
|
|
487
|
+
// fall through to the best-effort tick below
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
// Lock-busy: count the stop as a stall tick anyway so the liveness breaker
|
|
491
|
+
// keeps advancing behind a wedged or leaked lock.
|
|
492
|
+
return await bump();
|
|
478
493
|
} catch {
|
|
479
494
|
return null;
|
|
480
495
|
}
|
|
@@ -547,7 +562,7 @@ export async function evaluateHandoffCursor({ projectRoot, now = Date.now(), loc
|
|
|
547
562
|
* invocation is a same-session duplicate inside the window. Fail-open — an
|
|
548
563
|
* unreadable/unlockable dedupe state must never disable the Stop coordinator.
|
|
549
564
|
*/
|
|
550
|
-
async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
|
|
565
|
+
export async function alreadyCoordinatedThisStop({ projectRoot, sessionKey, now, dedupeWindowMs, lockBudgetMs }) {
|
|
551
566
|
const statePath = path.join(projectRoot, '.ukit', 'storage', 'cache', 'stop-coordinator', 'state.json');
|
|
552
567
|
try {
|
|
553
568
|
const outcome = await withAsyncLock(statePath, { deadlineMs: lockBudgetMs }, async () => {
|