tickmarkr 2.6.2 → 2.6.4
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 +12 -2
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +26 -5
- package/dist/adapters/model-lints.d.ts +1 -0
- package/dist/adapters/model-lints.js +48 -1
- package/dist/adapters/model-windows.d.ts +2 -1
- package/dist/adapters/model-windows.js +18 -7
- package/dist/adapters/types.d.ts +6 -0
- package/dist/cli/commands/approve.d.ts +6 -3
- package/dist/cli/commands/approve.js +43 -6
- package/dist/cli/commands/doctor.d.ts +8 -2
- package/dist/cli/commands/doctor.js +6 -1
- package/dist/cli/commands/fleet.js +264 -59
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/plan.js +13 -8
- package/dist/cli/commands/report.d.ts +40 -2
- package/dist/cli/commands/report.js +278 -11
- package/dist/cli/commands/resume.js +2 -4
- package/dist/cli/commands/run.d.ts +11 -0
- package/dist/cli/commands/run.js +33 -5
- package/dist/cli/commands/status.js +49 -4
- package/dist/cli/commands/verify.js +332 -121
- package/dist/compile/native.js +137 -0
- package/dist/config/config.d.ts +40 -9
- package/dist/config/config.js +133 -15
- package/dist/config/fleet-overlay.d.ts +13 -3
- package/dist/config/fleet-overlay.js +12 -8
- package/dist/drivers/herdr.d.ts +12 -0
- package/dist/drivers/herdr.js +51 -0
- package/dist/drivers/orca.d.ts +9 -1
- package/dist/drivers/orca.js +29 -7
- package/dist/drivers/types.d.ts +2 -0
- package/dist/drivers/types.js +2 -2
- package/dist/gates/acceptance.d.ts +7 -0
- package/dist/gates/acceptance.js +27 -5
- package/dist/gates/baseline.d.ts +20 -1
- package/dist/gates/baseline.js +100 -20
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +12 -2
- package/dist/gates/llm.d.ts +6 -0
- package/dist/gates/llm.js +27 -8
- package/dist/gates/review.d.ts +6 -1
- package/dist/gates/review.js +122 -32
- package/dist/gates/run-gates.d.ts +54 -3
- package/dist/gates/run-gates.js +331 -45
- package/dist/gates/test-manifest.d.ts +42 -0
- package/dist/gates/test-manifest.js +69 -10
- package/dist/route/router.d.ts +23 -1
- package/dist/route/router.js +54 -16
- package/dist/run/consult.d.ts +3 -1
- package/dist/run/consult.js +4 -2
- package/dist/run/daemon.d.ts +2 -1
- package/dist/run/daemon.js +349 -79
- package/dist/run/interactive-seed.d.ts +4 -0
- package/dist/run/interactive-seed.js +35 -9
- package/dist/run/journal.d.ts +123 -1
- package/dist/run/journal.js +480 -17
- package/dist/run/lease.d.ts +13 -0
- package/dist/run/lease.js +45 -0
- package/dist/run/protocol.d.ts +15 -0
- package/dist/run/protocol.js +11 -1
- package/dist/run/receipt-resolver.d.ts +22 -0
- package/dist/run/receipt-resolver.js +40 -1
- package/dist/run/repair-selection.d.ts +11 -1
- package/dist/run/repair-selection.js +17 -9
- package/dist/run/wall-budget.d.ts +48 -0
- package/dist/run/wall-budget.js +280 -0
- package/dist/tui/cockpit/live-store.d.ts +6 -0
- package/dist/tui/cockpit/live-store.js +36 -11
- package/dist/tui/cockpit/run-cockpit.js +2 -2
- package/dist/tui/cockpit/run-view.d.ts +2 -1
- package/dist/tui/cockpit/run-view.js +13 -9
- package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
- package/dist/tui/cockpit/setup-cockpit.js +4 -0
- package/package.json +2 -1
- package/schema/config.schema.json +8 -1
- package/skills/tickmarkr-auto/SKILL.md +2 -2
- package/skills/tickmarkr-loop/SKILL.md +17 -5
- package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
package/dist/run/journal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -6,9 +7,10 @@ import { z } from "zod";
|
|
|
6
7
|
import { channelKey, shq, TokenUsageSchema } from "../adapters/types.js";
|
|
7
8
|
import { graphDefinitionHash, stateDirName, taskContentDigest, taskDefinitionFingerprint, tickmarkrDir } from "../graph/graph.js";
|
|
8
9
|
import { GATE_NAMES, TIERS } from "../graph/schema.js";
|
|
9
|
-
import { channelRouteIdentity } from "../route/preference.js";
|
|
10
|
+
import { channelRouteIdentity, modelProvider } from "../route/preference.js";
|
|
10
11
|
import { buildProfile, classify } from "../route/profile.js";
|
|
11
12
|
import { DecisionEventSchema, trackJournalRows, } from "./protocol.js";
|
|
13
|
+
import { PRESERVE_COMMIT_SUBJECT, PRESERVE_PRODUCER_TRAILER } from "./git.js";
|
|
12
14
|
import { normalizeGateOutcome } from "./outcome.js";
|
|
13
15
|
import { redactSecrets } from "./redact.js";
|
|
14
16
|
export function phaseForGate(gate) {
|
|
@@ -713,21 +715,53 @@ export function normalizeGateFailure(details) {
|
|
|
713
715
|
// operator approval is a new engagement, and nothing else resets the count. Resume re-measurements
|
|
714
716
|
// are excluded because the interrupted attempt already bought the result they confirm.
|
|
715
717
|
export const GATE_FINGERPRINT_CAP = 2;
|
|
718
|
+
/** The invocation a gate row's receipt names — the identity of the execution that observed it. */
|
|
719
|
+
export const receiptOrigin = (data) => {
|
|
720
|
+
const receipt = data.evidenceReceipt ?? (Array.isArray(data.evidenceReceipts) ? data.evidenceReceipts[0] : undefined);
|
|
721
|
+
const id = receipt && typeof receipt === "object" ? receipt.invocationId : undefined;
|
|
722
|
+
return typeof id === "string" && id ? `invocation:${id}` : undefined;
|
|
723
|
+
};
|
|
724
|
+
// OBS-1106 residual: occurrences are independent OBSERVATIONS, not rows. A journal replay
|
|
725
|
+
// (`replayedFromAttempt`) and a verdict-cache hit (`reused`) re-state a measurement another row
|
|
726
|
+
// already bought, so each resolves to that row's origin — its receipt's invocation when it carries
|
|
727
|
+
// one, else the attempt (replay) or the newest observation on the same subject (cache) — and one
|
|
728
|
+
// observation counts once however many copies the ledger holds, across resume. Only a fresh
|
|
729
|
+
// execution mints a new origin.
|
|
716
730
|
export function identicalGateFailures(events, taskId, gate, normalized) {
|
|
717
|
-
|
|
718
|
-
|
|
731
|
+
const counted = new Set();
|
|
732
|
+
const byAttempt = new Map();
|
|
733
|
+
const bySubject = new Map();
|
|
734
|
+
effectiveEvents(events).forEach((e, i) => {
|
|
719
735
|
if (e.taskId !== taskId)
|
|
720
|
-
|
|
721
|
-
if (e.event === "task-approved")
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
736
|
+
return;
|
|
737
|
+
if (e.event === "task-approved") {
|
|
738
|
+
counted.clear();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (e.event !== "gate-result" || e.data.gate !== gate)
|
|
742
|
+
return;
|
|
743
|
+
const commit = typeof e.data.commit === "string" ? e.data.commit : undefined;
|
|
744
|
+
const origin = typeof e.data.replayedFromAttempt === "number"
|
|
745
|
+
? byAttempt.get(e.data.replayedFromAttempt) ?? receiptOrigin(e.data) ?? `attempt:${e.data.replayedFromAttempt}`
|
|
746
|
+
: e.data.reused === true
|
|
747
|
+
? receiptOrigin(e.data) ?? (commit ? bySubject.get(commit) : undefined) ?? `reused:${commit ?? i}`
|
|
748
|
+
: receiptOrigin(e.data) ?? `row:${i}`;
|
|
749
|
+
if (typeof e.data.attempt === "number")
|
|
750
|
+
byAttempt.set(e.data.attempt, origin);
|
|
751
|
+
if (commit)
|
|
752
|
+
bySubject.set(commit, origin);
|
|
753
|
+
if (e.data.pass === false && e.data.replayMeasurement !== true && typeof e.data.details === "string"
|
|
726
754
|
&& normalizeGateFailure(e.data.details) === normalized)
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
return
|
|
730
|
-
}
|
|
755
|
+
counted.add(origin);
|
|
756
|
+
});
|
|
757
|
+
return counted.size;
|
|
758
|
+
}
|
|
759
|
+
// OBS-1072 add.2: the controls a terminal would consume — OSC strings (ESC ] … BEL/ST), CSI sequences
|
|
760
|
+
// (SGR included), other ESC sequences and stray C0 bytes but tab and newline. Applied only to the
|
|
761
|
+
// human-facing excerpts a worker brief or a scope hint reads; the journal row, the receipt bytes and
|
|
762
|
+
// the failure fingerprint keep the raw text.
|
|
763
|
+
const TERMINAL_CONTROL_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
764
|
+
export const readableExcerpt = (text) => text.replace(TERMINAL_CONTROL_RE, "");
|
|
731
765
|
export function repairReachSinceApproval(events, taskId) {
|
|
732
766
|
const repairs = [];
|
|
733
767
|
for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
|
|
@@ -899,7 +933,7 @@ export function journaledFailureBrief(events, taskId) {
|
|
|
899
933
|
rows = [];
|
|
900
934
|
else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
|
|
901
935
|
&& typeof e.data.details === "string")
|
|
902
|
-
rows.push(`${e.data.gate}: ${e.data.details}`);
|
|
936
|
+
rows.push(`${e.data.gate}: ${readableExcerpt(e.data.details)}`);
|
|
903
937
|
else if (e.event === "delivery-readiness-failed" && typeof e.data.transcript === "string") {
|
|
904
938
|
rows.push(`dispatch: delivery readiness failed after ${e.data.waitedMs}ms; pane transcript:\n${e.data.transcript}`);
|
|
905
939
|
}
|
|
@@ -939,6 +973,17 @@ export function observedReviewFingerprints(finding) {
|
|
|
939
973
|
return [...new Set([finding.fingerprint, ...observed])];
|
|
940
974
|
}
|
|
941
975
|
const reviewNoteIdentity = (note) => note.replace(LINE_REF_RE, "").replace(/\s+/g, " ").trim();
|
|
976
|
+
/** OBS-1195: the anchor row a verdict's recorded settled comment spells, or undefined if malformed. */
|
|
977
|
+
function settledAnchorRow(anchor) {
|
|
978
|
+
if (!anchor || typeof anchor !== "object")
|
|
979
|
+
return undefined;
|
|
980
|
+
const { path, line, body, disposition } = anchor;
|
|
981
|
+
if (typeof path !== "string" || !Number.isInteger(line) || typeof body !== "string")
|
|
982
|
+
return undefined;
|
|
983
|
+
if (disposition !== "deferred" && disposition !== "resolved")
|
|
984
|
+
return undefined;
|
|
985
|
+
return structuredFindings("review", `- ${path}:${line} — ${body}`).find((finding) => finding.class === "review:anchored");
|
|
986
|
+
}
|
|
942
987
|
function distinctReviewFinding(row) {
|
|
943
988
|
const identity = JSON.stringify([reviewNoteIdentity(row.note), row.codeIdentity ?? null]);
|
|
944
989
|
const symbol = `${row.symbol}#${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
|
|
@@ -1042,6 +1087,23 @@ export function carryReviewFindings(priors, rows) {
|
|
|
1042
1087
|
* after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
|
|
1043
1088
|
* row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
|
|
1044
1089
|
*/
|
|
1090
|
+
/** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
|
|
1091
|
+
* never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
|
|
1092
|
+
export function priorJudgments(events, taskId) {
|
|
1093
|
+
const out = [];
|
|
1094
|
+
for (const e of events) {
|
|
1095
|
+
const j = e.taskId === taskId && e.event === "gate-result" && e.data.gate === "acceptance" ? e.data.judgment : undefined;
|
|
1096
|
+
if (!j || typeof j !== "object")
|
|
1097
|
+
continue;
|
|
1098
|
+
const { commit, judge, criteria } = j;
|
|
1099
|
+
if (typeof commit !== "string" || !commit || !Array.isArray(criteria))
|
|
1100
|
+
continue;
|
|
1101
|
+
const rows = criteria.filter((c) => !!c && typeof c.id === "string" && typeof c.key === "string" && typeof c.met === "boolean"
|
|
1102
|
+
&& Array.isArray(c.paths) && c.paths.every((p) => typeof p === "string"));
|
|
1103
|
+
out.unshift({ commit, ...(typeof judge === "string" ? { judge } : {}), criteria: rows });
|
|
1104
|
+
}
|
|
1105
|
+
return out;
|
|
1106
|
+
}
|
|
1045
1107
|
export function outstandingReviewFindings(events, taskId) {
|
|
1046
1108
|
let open = [];
|
|
1047
1109
|
for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
|
|
@@ -1059,6 +1121,9 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
1059
1121
|
continue;
|
|
1060
1122
|
// A failed review can resolve one chain while re-raising another. Only observed, uniquely
|
|
1061
1123
|
// matched spellings retire a chain; skipped/no-verdict rows were excluded above.
|
|
1124
|
+
// OBS-1195: every parent chain this verdict resolved or explicitly deferred; an anchor bound to one
|
|
1125
|
+
// retires whether it was carried in or restated by this same verdict.
|
|
1126
|
+
const retiredParents = new Set();
|
|
1062
1127
|
if (Array.isArray(e.data.resolved)) {
|
|
1063
1128
|
const resolved = e.data.resolved;
|
|
1064
1129
|
const settled = new Set(resolved.flatMap((id) => {
|
|
@@ -1066,8 +1131,45 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
1066
1131
|
&& observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)));
|
|
1067
1132
|
return matches.length === 1 ? matches : [];
|
|
1068
1133
|
}));
|
|
1134
|
+
// OBS-1195: an anchor explicitly bound to a settled chain retires with it; unbound ones stay.
|
|
1135
|
+
for (const id of [...settled].flatMap(observedReviewFingerprints))
|
|
1136
|
+
retiredParents.add(id);
|
|
1069
1137
|
open = open.filter((finding) => !settled.has(finding));
|
|
1070
1138
|
}
|
|
1139
|
+
// OBS-1195: a failed verdict that restates a bound anchor's parent ONLY as a deferral (its deferred
|
|
1140
|
+
// entry echoes the prior's id and no material entry claims it) has explicitly deferred that parent,
|
|
1141
|
+
// so the anchor retires. The parent chain itself keeps whatever the closure lists said of it.
|
|
1142
|
+
// A material row's `reraisedFrom` is this verdict's claim only when its own reraised list names that
|
|
1143
|
+
// id; a link an earlier verdict drew (carried metadata) is lineage and claims nothing here. Without a
|
|
1144
|
+
// reraised list to check against, every link counts as a claim — fail closed.
|
|
1145
|
+
const rows = findingRows(e, "review");
|
|
1146
|
+
const reraisedHere = Array.isArray(e.data.reraised) ? e.data.reraised.filter((id) => typeof id === "string") : undefined;
|
|
1147
|
+
const claimedByMaterial = new Set(rows.filter((row) => !isDeferredFinding(row) && row.reraisedFrom
|
|
1148
|
+
&& (reraisedHere === undefined || reraisedHere.some((id) => reviewFingerprintMatches(id, row.reraisedFrom)))).map((row) => row.reraisedFrom));
|
|
1149
|
+
const deferredParents = rows.filter((row) => isDeferredFinding(row) && row.reraisedFrom && !claimedByMaterial.has(row.reraisedFrom))
|
|
1150
|
+
.map((row) => row.reraisedFrom);
|
|
1151
|
+
for (const finding of open) {
|
|
1152
|
+
if (finding.class !== "review:material" || !observedReviewFingerprints(finding).some((fp) => deferredParents.includes(fp)))
|
|
1153
|
+
continue;
|
|
1154
|
+
for (const id of observedReviewFingerprints(finding))
|
|
1155
|
+
retiredParents.add(id);
|
|
1156
|
+
}
|
|
1157
|
+
const boundToRetired = (finding) => finding.boundTo !== undefined && retiredParents.has(finding.boundTo);
|
|
1158
|
+
open = open.filter((finding) => !boundToRetired(finding));
|
|
1159
|
+
// OBS-1195: a later verdict that re-anchors a carried anchor to a deferred entry or a resolved prior
|
|
1160
|
+
// settles that anchor explicitly. Only its own identity (path, symbol and note), uniquely matched,
|
|
1161
|
+
// retires it; a bare path coincidence binds nothing.
|
|
1162
|
+
if (Array.isArray(e.data.settledAnchors)) {
|
|
1163
|
+
const retired = new Set(e.data.settledAnchors.flatMap((anchor) => {
|
|
1164
|
+
const row = settledAnchorRow(anchor);
|
|
1165
|
+
// Identity, not fingerprint: a collision-disambiguated anchor carries a `symbol#hash` spelling.
|
|
1166
|
+
const matches = row ? open.filter((finding) => finding.class === "review:anchored" && finding.path === row.path
|
|
1167
|
+
&& (finding.symbol === row.symbol || finding.symbol.startsWith(`${row.symbol}#`))
|
|
1168
|
+
&& reviewNoteIdentity(finding.note) === reviewNoteIdentity(row.note)) : [];
|
|
1169
|
+
return matches.length === 1 ? matches : [];
|
|
1170
|
+
}));
|
|
1171
|
+
open = open.filter((finding) => !retired.has(finding));
|
|
1172
|
+
}
|
|
1071
1173
|
if (e.data.pass !== false) {
|
|
1072
1174
|
// a later review PASSED on this task: every finding it BLOCKED on is settled …
|
|
1073
1175
|
open = open.filter(isDeferredFinding);
|
|
@@ -1077,11 +1179,39 @@ export function outstandingReviewFindings(events, taskId) {
|
|
|
1077
1179
|
// the very next round — the same silent drop by a different door.
|
|
1078
1180
|
open = carryReviewFindings(open, findingRows(e, "review").filter(isDeferredFinding));
|
|
1079
1181
|
}
|
|
1080
|
-
else
|
|
1081
|
-
|
|
1182
|
+
else {
|
|
1183
|
+
// OBS-1195: a repeated comment bound to a parent this verdict resolved or deferred is not re-seated.
|
|
1184
|
+
open = carryReviewFindings(open, rows).filter((finding) => !boundToRetired(finding));
|
|
1185
|
+
}
|
|
1082
1186
|
}
|
|
1083
1187
|
return open;
|
|
1084
1188
|
}
|
|
1189
|
+
/**
|
|
1190
|
+
* OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
|
|
1191
|
+
* re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
|
|
1192
|
+
* count, so an unrelated finding sharing a path never lengthens it.
|
|
1193
|
+
*/
|
|
1194
|
+
export function reraisedReviewChains(events, taskId) {
|
|
1195
|
+
let verdicts = [];
|
|
1196
|
+
for (const e of effectiveEvents(events)) {
|
|
1197
|
+
if (e.taskId !== taskId)
|
|
1198
|
+
continue;
|
|
1199
|
+
if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
|
|
1200
|
+
verdicts = [];
|
|
1201
|
+
if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
|
|
1202
|
+
continue;
|
|
1203
|
+
if (e.data.unparseable === true || e.data.noVerdict === true || e.data.cause !== undefined)
|
|
1204
|
+
continue;
|
|
1205
|
+
verdicts = e.data.pass === false ? [...verdicts, e] : [];
|
|
1206
|
+
}
|
|
1207
|
+
return outstandingReviewFindings(events, taskId)
|
|
1208
|
+
.filter((finding) => finding.class === "review:material")
|
|
1209
|
+
.map((finding) => ({
|
|
1210
|
+
finding,
|
|
1211
|
+
reraises: verdicts.filter((e) => Array.isArray(e.data.reraised) && e.data.reraised
|
|
1212
|
+
.some((id) => observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)))).length,
|
|
1213
|
+
}));
|
|
1214
|
+
}
|
|
1085
1215
|
/** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
|
|
1086
1216
|
export function pendingRepairFindings(events, taskId) {
|
|
1087
1217
|
const e = decisionForNextDispatch(events, taskId, "repair-attempt");
|
|
@@ -1808,7 +1938,11 @@ export class Journal {
|
|
|
1808
1938
|
const reducedData = event === "run-end"
|
|
1809
1939
|
? (() => {
|
|
1810
1940
|
const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
|
|
1811
|
-
|
|
1941
|
+
const reduced = preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
|
|
1942
|
+
// C1: the terminal record carries the owed-check fold as of this append, revalidated from
|
|
1943
|
+
// each discharge's artifact — a reopened journal re-reduces, it never copies a prior total.
|
|
1944
|
+
// Canonical (validated) rows are strict and keep their schema; the live producer is the tuple.
|
|
1945
|
+
return decisionRow ? reduced : { ...reduced, owedChecks: foldOwedChecks(priorEvents, join(this.dir, "..", "..", "..")) };
|
|
1812
1946
|
})()
|
|
1813
1947
|
: event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
|
|
1814
1948
|
? {
|
|
@@ -2255,3 +2389,332 @@ export class Journal {
|
|
|
2255
2389
|
.filter((row) => row && typeof row === "object" && "kind" in row && row.kind === "judge");
|
|
2256
2390
|
}
|
|
2257
2391
|
}
|
|
2392
|
+
// ── C1 (v2.6.4): owed checks ─────────────────────────────────────────────────────────────────────
|
|
2393
|
+
// A waiver accepts risk on ONE immutable subject. The same task-approved row that satisfies the gate
|
|
2394
|
+
// carries the obligation it leaves behind: run, task, gate, the exact base..head range and its
|
|
2395
|
+
// canonical subject, the acceptance criteria, the run-derived lifetime patch authors, the cause and
|
|
2396
|
+
// the evidence that would discharge it. The fold below is the only reader. It keeps every waiver as
|
|
2397
|
+
// accepted-risk history, subtracts a discharge only while its proof still revalidates, and answers
|
|
2398
|
+
// "unknown" — never zero — for a waiver without a well-formed obligation or a proof that no longer holds.
|
|
2399
|
+
export const OWED_CHECK_VERSION = 1;
|
|
2400
|
+
export const OWED_DISCHARGE_EVENT = "owed-check-discharged";
|
|
2401
|
+
const owedGit = (cwd, args, input) => execFileSync("git", args, {
|
|
2402
|
+
cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...(input === undefined ? { stdio: ["ignore", "pipe", "pipe"] } : { input }),
|
|
2403
|
+
});
|
|
2404
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
2405
|
+
export const owedCriteria = (acceptance) => sha256(JSON.stringify(acceptance));
|
|
2406
|
+
/** The daemon's canonical gate subject for base..head (daemon.ts gateCommitSubject), byte for byte. */
|
|
2407
|
+
export const owedSubject = (cwd, base, head) => sha256(owedGit(cwd, ["log", "--reverse", "--format=%T%x00%an%x00%ae%x00%cn%x00%ce%x00%B%x1e", `${base}..${head}`]));
|
|
2408
|
+
// Ownership joins on --stable ids (whitespace-blind: a re-indented carry keeps its author, which only
|
|
2409
|
+
// widens exclusions). Range identity uses --verbatim: re-indented content is a different program.
|
|
2410
|
+
const patchIdOf = (cwd, diff, mode = "--stable") => owedGit(cwd, ["patch-id", mode], diff).trim().split(/\s+/)[0] || undefined;
|
|
2411
|
+
/** Ordered verbatim patch ids of the commits in base..head; an empty commit authored no patch. */
|
|
2412
|
+
export const rangePatches = (cwd, base, head) => owedGit(cwd, ["rev-list", "--reverse", `${base}..${head}`]).trim().split("\n").filter(Boolean)
|
|
2413
|
+
.map((commit) => patchIdOf(cwd, owedGit(cwd, ["show", "--format=", "--binary", commit]), "--verbatim"))
|
|
2414
|
+
.filter((id) => id !== undefined);
|
|
2415
|
+
/** Verbatim patch identity of the whole change base..head introduces. */
|
|
2416
|
+
export const rangePatch = (cwd, base, head) => patchIdOf(cwd, owedGit(cwd, ["diff", "--binary", base, head]), "--verbatim") ?? "empty";
|
|
2417
|
+
const COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
2418
|
+
/**
|
|
2419
|
+
* `head` is the task's own recorded integration merge of the obligation's range — exactly. Its first
|
|
2420
|
+
* parent is `mergeBase`; its second parent replays the waived history source tree for source tree from
|
|
2421
|
+
* a base with the waived base's tree (a carry may mint new commit objects, never new content — and
|
|
2422
|
+
* patch ids alone ignore where a hunk lands); its tree is git's own clean merge of the two (no
|
|
2423
|
+
* resolution, no amend); and it introduced the same ordered verbatim patch series and total patch.
|
|
2424
|
+
* Squashed, reordered, relocated, re-indented, reverted or conflict-resolved history fails one of
|
|
2425
|
+
* these and is refused as ambiguous; containment never counts.
|
|
2426
|
+
* Both verify (before discharging) and the fold (re-reading every discharge) decide through here.
|
|
2427
|
+
*/
|
|
2428
|
+
export function integrationMapped(cwd, events, o, mergeBase, head) {
|
|
2429
|
+
if (typeof mergeBase !== "string" || typeof head !== "string" || !COMMIT_ID.test(mergeBase) || !COMMIT_ID.test(head))
|
|
2430
|
+
return false;
|
|
2431
|
+
if (!events.some((e) => e.event === "merge" && e.taskId === o.taskId && e.data.commit === head))
|
|
2432
|
+
return false;
|
|
2433
|
+
try {
|
|
2434
|
+
const parents = owedGit(cwd, ["show", "-s", "--format=%P", head]).trim().split(" ");
|
|
2435
|
+
if (parents.length !== 2 || parents[0] !== mergeBase)
|
|
2436
|
+
return false;
|
|
2437
|
+
const [, carried] = parents;
|
|
2438
|
+
const tree = (rev) => owedGit(cwd, ["rev-parse", `${rev}^{tree}`]).trim();
|
|
2439
|
+
const trees = (from, to) => owedGit(cwd, ["log", "--reverse", "--format=%T", `${from}..${to}`]).trim();
|
|
2440
|
+
if (tree(owedGit(cwd, ["merge-base", mergeBase, carried]).trim()) !== tree(o.base) || trees(mergeBase, carried) !== trees(o.base, o.head))
|
|
2441
|
+
return false;
|
|
2442
|
+
// A conflicted merge-tree exits 1 and throws; a clean one prints its tree first.
|
|
2443
|
+
if (owedGit(cwd, ["merge-tree", "--write-tree", mergeBase, carried]).trim().split("\n")[0] !== tree(head))
|
|
2444
|
+
return false;
|
|
2445
|
+
const series = rangePatches(cwd, mergeBase, carried);
|
|
2446
|
+
return series.length === o.patches.length && series.every((id, i) => id === o.patches[i])
|
|
2447
|
+
&& rangePatch(cwd, mergeBase, head) === o.patch;
|
|
2448
|
+
}
|
|
2449
|
+
catch {
|
|
2450
|
+
return false;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Lifetime patch authors of base..head, read from the run: the daemon's subjectAuthors fold
|
|
2455
|
+
* (daemon.ts, not exported and not this task's to edit) restated synchronously so the waive row is
|
|
2456
|
+
* written without yielding. Recreation rows name SOURCE hashes, so ownership joins by stable patch
|
|
2457
|
+
* identity across changed commit objects; a gate-only restore (resume-restore's seat, a recreation
|
|
2458
|
+
* after work started) never becomes an author. Unreadable history names an unknown author.
|
|
2459
|
+
*/
|
|
2460
|
+
export function owedAuthors(events, taskId, cwd, base, head) {
|
|
2461
|
+
const cache = new Map();
|
|
2462
|
+
const patches = (commits) => {
|
|
2463
|
+
const ids = new Set();
|
|
2464
|
+
for (const commit of commits) {
|
|
2465
|
+
if (!cache.has(commit))
|
|
2466
|
+
cache.set(commit, patchIdOf(cwd, owedGit(cwd, ["show", "--format=", "--binary", commit])));
|
|
2467
|
+
const id = cache.get(commit);
|
|
2468
|
+
if (id)
|
|
2469
|
+
ids.add(id);
|
|
2470
|
+
}
|
|
2471
|
+
return ids;
|
|
2472
|
+
};
|
|
2473
|
+
let current;
|
|
2474
|
+
let previous;
|
|
2475
|
+
let awaitingCarry = false;
|
|
2476
|
+
const owners = new Map();
|
|
2477
|
+
const preservedOwner = new Map();
|
|
2478
|
+
const attribute = (ids, attempt) => {
|
|
2479
|
+
for (const id of ids) {
|
|
2480
|
+
if (attempt?.incoming.has(id))
|
|
2481
|
+
continue;
|
|
2482
|
+
const authors = owners.get(id) ?? new Set();
|
|
2483
|
+
authors.add(preservedOwner.get(id) ?? attempt?.author ?? "unknown author (missing task-dispatch assignment)");
|
|
2484
|
+
owners.set(id, authors);
|
|
2485
|
+
}
|
|
2486
|
+
};
|
|
2487
|
+
try {
|
|
2488
|
+
for (const row of events) {
|
|
2489
|
+
if (row.taskId !== taskId)
|
|
2490
|
+
continue;
|
|
2491
|
+
const preserved = [row.data.ref, row.data.preservedRef].find((v) => typeof v === "string" && v.startsWith("refs/tickmarkr/preserved/"));
|
|
2492
|
+
if (preserved) {
|
|
2493
|
+
const [commit, subject, trailer] = owedGit(cwd, ["show", "-s", `--format=%H%n%s%n%(trailers:key=${PRESERVE_PRODUCER_TRAILER},valueonly)`, `${preserved}^{commit}`]).trim().split("\n");
|
|
2494
|
+
if (commit && subject === PRESERVE_COMMIT_SUBJECT) {
|
|
2495
|
+
const owner = typeof row.data.producer === "string" ? row.data.producer
|
|
2496
|
+
: trailer?.trim().replace(/ attempt \d+$/, "") || "unknown author (legacy unattributed preservation)";
|
|
2497
|
+
for (const id of patches([commit])) {
|
|
2498
|
+
const prior = preservedOwner.get(id);
|
|
2499
|
+
if (prior === undefined || prior === "unknown" || prior.startsWith("unknown author"))
|
|
2500
|
+
preservedOwner.set(id, owner);
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (row.event === "task-dispatch") {
|
|
2505
|
+
previous = current;
|
|
2506
|
+
const a = row.data.assignment;
|
|
2507
|
+
current = { author: typeof a?.adapter === "string" && typeof a?.model === "string"
|
|
2508
|
+
? `${a.adapter}:${a.model}` : "unknown author (missing task-dispatch assignment)", incoming: new Set() };
|
|
2509
|
+
awaitingCarry = true;
|
|
2510
|
+
}
|
|
2511
|
+
else if (row.event === "worktree-recreation") {
|
|
2512
|
+
const carried = patches(Array.isArray(row.data.carried) ? row.data.carried : []);
|
|
2513
|
+
attribute(carried, awaitingCarry ? previous : current);
|
|
2514
|
+
if (awaitingCarry && current)
|
|
2515
|
+
current.incoming = carried;
|
|
2516
|
+
awaitingCarry = false;
|
|
2517
|
+
}
|
|
2518
|
+
else if (["worker-launch", "worker-result", "gate-result", "task-human"].includes(row.event)) {
|
|
2519
|
+
awaitingCarry = false;
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
const subject = patches(owedGit(cwd, ["rev-list", "--reverse", `${base}..${head}`]).trim().split("\n").filter(Boolean));
|
|
2523
|
+
attribute(subject, current);
|
|
2524
|
+
return [...new Set([...subject].flatMap((id) => [...(owners.get(id) ?? [])]))].sort();
|
|
2525
|
+
}
|
|
2526
|
+
catch (error) {
|
|
2527
|
+
return [`unknown author (${String(error).split("\n")[0]})`];
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* The obligation a waive of `gate` leaves on the task branch's current range. Synchronous on purpose:
|
|
2532
|
+
* approve writes it into the waive row under the approval serialization without yielding, so a live
|
|
2533
|
+
* daemon's boundary sees the same single append it always did. Anything not bound exactly — the
|
|
2534
|
+
* waived row measured another subject, unreadable refs, unresolved authors, unreadable criteria — is
|
|
2535
|
+
* recorded, never guessed, as known: false.
|
|
2536
|
+
*/
|
|
2537
|
+
export function captureOwedCheck(input) {
|
|
2538
|
+
const { cwd, runId, taskId, gate, events } = input;
|
|
2539
|
+
let base = "unknown", head = "unknown", subject = "unknown", patch = "unknown";
|
|
2540
|
+
let patches = [];
|
|
2541
|
+
let authors = [];
|
|
2542
|
+
let unknownReason;
|
|
2543
|
+
try {
|
|
2544
|
+
head = owedGit(cwd, ["rev-parse", "--verify", `${input.taskRef}^{commit}`]).trim();
|
|
2545
|
+
base = owedGit(cwd, ["merge-base", input.integrationRef, head]).trim();
|
|
2546
|
+
subject = owedSubject(cwd, base, head);
|
|
2547
|
+
const measured = [...events].reverse().find((e) => e.taskId === taskId && e.event === "gate-result" && e.data.gate === gate);
|
|
2548
|
+
if (measured?.data.commit !== subject) {
|
|
2549
|
+
throw new Error(`the waived ${gate} row measured ${String(measured?.data.commit ?? "no subject")}, not ${input.taskRef}'s range`);
|
|
2550
|
+
}
|
|
2551
|
+
patches = rangePatches(cwd, base, head);
|
|
2552
|
+
patch = rangePatch(cwd, base, head);
|
|
2553
|
+
authors = owedAuthors(events, taskId, cwd, base, head);
|
|
2554
|
+
if (!authors.length || authors.some((a) => a.startsWith("unknown")))
|
|
2555
|
+
throw new Error(`unresolved patch authors: ${authors.join(", ") || "none"}`);
|
|
2556
|
+
}
|
|
2557
|
+
catch (error) {
|
|
2558
|
+
unknownReason = (error instanceof Error ? error.message : String(error)).split("\n")[0];
|
|
2559
|
+
}
|
|
2560
|
+
if (!Array.isArray(input.acceptance))
|
|
2561
|
+
unknownReason ??= "task acceptance criteria unreadable";
|
|
2562
|
+
const files = Array.isArray(input.files) && input.files.every((f) => typeof f === "string") ? input.files : undefined;
|
|
2563
|
+
if (!files)
|
|
2564
|
+
unknownReason ??= "task file scope unreadable";
|
|
2565
|
+
if (!input.declared?.length)
|
|
2566
|
+
unknownReason ??= "declared channels unreadable";
|
|
2567
|
+
const criteria = Array.isArray(input.acceptance) ? owedCriteria(input.acceptance) : "unknown";
|
|
2568
|
+
return {
|
|
2569
|
+
version: OWED_CHECK_VERSION,
|
|
2570
|
+
id: sha256(JSON.stringify([runId, taskId, gate, base, head, subject, criteria])).slice(0, 16),
|
|
2571
|
+
runId, taskId, gate, base, head, subject, patch, patches, criteria, files: files ?? [], authors,
|
|
2572
|
+
declared: [...(input.declared ?? [])].map(({ key, vendor }) => ({ key, vendor })), cause: input.cause,
|
|
2573
|
+
evidence: `tickmarkr verify --base ${base} --task ${taskId} --record ${runId}: ${gate} passing on ${base}..${head} or on its recorded integration merge, reviewed outside the authors' vendors`,
|
|
2574
|
+
disposition: "accepted-risk",
|
|
2575
|
+
known: unknownReason === undefined,
|
|
2576
|
+
...(unknownReason === undefined ? {} : { unknownReason }),
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
/** A bound fact: a non-empty string that capture did not record as unknown. */
|
|
2580
|
+
const isBound = (v) => typeof v === "string" && v !== "" && !v.startsWith("unknown");
|
|
2581
|
+
/** Every required field present and well-typed; anything else is malformed evidence, never a known check. */
|
|
2582
|
+
const isOwedCheck = (value, taskId, gate) => {
|
|
2583
|
+
if (typeof value !== "object" || value === null)
|
|
2584
|
+
return false;
|
|
2585
|
+
const o = value;
|
|
2586
|
+
return o.version === OWED_CHECK_VERSION && o.known === true && o.disposition === "accepted-risk"
|
|
2587
|
+
&& o.taskId === taskId && o.gate === gate && GATE_NAMES.includes(o.gate)
|
|
2588
|
+
&& [o.id, o.runId, o.taskId, o.base, o.head, o.subject, o.patch, o.criteria].every(isBound)
|
|
2589
|
+
&& typeof o.cause === "string" && typeof o.evidence === "string"
|
|
2590
|
+
&& Array.isArray(o.patches) && o.patches.every(isBound)
|
|
2591
|
+
&& Array.isArray(o.authors) && o.authors.length > 0 && o.authors.every(isBound)
|
|
2592
|
+
&& Array.isArray(o.files) && o.files.every((f) => typeof f === "string")
|
|
2593
|
+
&& Array.isArray(o.declared) && o.declared.length > 0 && o.declared.every(isChannelFact);
|
|
2594
|
+
};
|
|
2595
|
+
const providerOf = (fact) => modelProvider(fact.key.slice(fact.key.indexOf(":") + 1), fact.vendor);
|
|
2596
|
+
/** A resolved channel: an `adapter:model` key and a bound vendor — never an empty or placeholder claim. */
|
|
2597
|
+
const isChannelFact = (f) => typeof f === "object" && f !== null
|
|
2598
|
+
&& typeof f.key === "string" && /^[^:\s]+:\S+$/.test(f.key) && isBound(f.vendor);
|
|
2599
|
+
/**
|
|
2600
|
+
* The trusted channel identities, key → vendors: what this repository's adapters declared under its config
|
|
2601
|
+
* when the waive was written, recorded on the obligation by approve. Declaration, not doctor health — a
|
|
2602
|
+
* later logout does not revoke a proof, but a seat not declared (or declared under another vendor) never
|
|
2603
|
+
* resolved. Read from the row, the fold resolves identically in every process and every checkout.
|
|
2604
|
+
*/
|
|
2605
|
+
const declaredVendors = (o) => {
|
|
2606
|
+
const vendors = new Map();
|
|
2607
|
+
for (const c of o.declared)
|
|
2608
|
+
vendors.set(c.key, (vendors.get(c.key) ?? new Set()).add(c.vendor));
|
|
2609
|
+
return vendors;
|
|
2610
|
+
};
|
|
2611
|
+
/** Why a discharge row no longer proves its obligation, re-read from its artifact; undefined when it does. */
|
|
2612
|
+
function dischargeProblem(row, o, events, cwd) {
|
|
2613
|
+
const d = row.data;
|
|
2614
|
+
if (row.taskId !== o.taskId)
|
|
2615
|
+
return "discharge names another task";
|
|
2616
|
+
if (d.criteria !== o.criteria)
|
|
2617
|
+
return "discharge proved other criteria";
|
|
2618
|
+
const exact = d.mapping === "exact" && d.mergeBase === o.base && d.head === o.head;
|
|
2619
|
+
// The integration mapping is re-proved from git on every fold, never taken from the row's claims.
|
|
2620
|
+
const integration = d.mapping === "integration" && integrationMapped(cwd, events, o, d.mergeBase, d.head);
|
|
2621
|
+
if (!exact && !integration)
|
|
2622
|
+
return "discharge range does not bind the obligation";
|
|
2623
|
+
let artifact;
|
|
2624
|
+
try {
|
|
2625
|
+
const bytes = readFileSync(String(d.artifactPath));
|
|
2626
|
+
if (createHash("sha256").update(bytes).digest("hex") !== d.artifactSha256)
|
|
2627
|
+
return "artifact hash mismatch";
|
|
2628
|
+
artifact = JSON.parse(bytes.toString("utf8")) ?? {};
|
|
2629
|
+
}
|
|
2630
|
+
catch {
|
|
2631
|
+
return "artifact unavailable";
|
|
2632
|
+
}
|
|
2633
|
+
if (artifact.head !== d.head || artifact.mergeBase !== d.mergeBase)
|
|
2634
|
+
return "artifact measured another range";
|
|
2635
|
+
if (artifact.green !== true)
|
|
2636
|
+
return "artifact verdict is not green";
|
|
2637
|
+
// The criteria the hash-bound evidence itself measured: the row's claim alone only repeats the obligation.
|
|
2638
|
+
if (artifact.criteria !== o.criteria)
|
|
2639
|
+
return "artifact measured other criteria";
|
|
2640
|
+
// The scope the waived task declared, exactly: a wider allowlist (--files '*') is weakened evidence.
|
|
2641
|
+
if (JSON.stringify(artifact.files) !== JSON.stringify(o.files))
|
|
2642
|
+
return "artifact gated another file scope";
|
|
2643
|
+
const rows = (Array.isArray(artifact.gateRows) ? artifact.gateRows : []);
|
|
2644
|
+
const gateRow = rows.find((r) => r?.gate === o.gate && r.pass === true && r.meta?.skipped !== true);
|
|
2645
|
+
if (!gateRow)
|
|
2646
|
+
return `artifact holds no passing ${o.gate} row`;
|
|
2647
|
+
if (o.gate === "review") {
|
|
2648
|
+
const reviewer = d.reviewer;
|
|
2649
|
+
const authors = Array.isArray(d.authorChannels) ? d.authorChannels : [];
|
|
2650
|
+
// The row's reviewer must be the seat the hash-bound review row recorded — key, vendor and provider.
|
|
2651
|
+
if (!isChannelFact(reviewer) || gateRow.meta?.reviewer !== reviewer.key || gateRow.meta.vendor !== reviewer.vendor
|
|
2652
|
+
|| gateRow.meta.provider !== providerOf(reviewer))
|
|
2653
|
+
return "unresolved reviewer";
|
|
2654
|
+
// ...and a channel the waive recorded as declared under exactly that vendor: agreement between the row
|
|
2655
|
+
// and the artifact only repeats a claim, so identity and vendor resolve against the obligation's own.
|
|
2656
|
+
const declared = declaredVendors(o);
|
|
2657
|
+
const resolves = (f) => { const v = declared.get(f.key); return v?.size === 1 && v.has(f.vendor); };
|
|
2658
|
+
// An author key stays excluded whatever vendor any row claims for it.
|
|
2659
|
+
if (o.authors.includes(reviewer.key))
|
|
2660
|
+
return "same-vendor reviewer (the reviewer is a recorded patch author)";
|
|
2661
|
+
if (!resolves(reviewer))
|
|
2662
|
+
return "unresolved reviewer (no declared channel with that identity and vendor)";
|
|
2663
|
+
if (!authors.every(isChannelFact) || !o.authors.every((key) => authors.some((a) => a.key === key)))
|
|
2664
|
+
return "unresolved author channel";
|
|
2665
|
+
if (authors.some((a) => authors.some((b) => a.key === b.key && a.vendor !== b.vendor)))
|
|
2666
|
+
return "unresolved author channel (inconsistent vendor claims)";
|
|
2667
|
+
if (!authors.every(resolves))
|
|
2668
|
+
return "unresolved author channel (no declared channel with that identity and vendor)";
|
|
2669
|
+
if (authors.some((a) => a.vendor === reviewer.vendor || providerOf(a) === providerOf(reviewer)))
|
|
2670
|
+
return "same-vendor reviewer";
|
|
2671
|
+
}
|
|
2672
|
+
return undefined;
|
|
2673
|
+
}
|
|
2674
|
+
/**
|
|
2675
|
+
* The CURRENT owed-check fold: accepted-risk history, outstanding debt, and whether it is known.
|
|
2676
|
+
* `cwd` is any checkout of the run's repository (integration discharges are re-proved from git).
|
|
2677
|
+
* It never throws: a fold that cannot be computed is unknown debt, not a crash and never zero.
|
|
2678
|
+
*/
|
|
2679
|
+
export function foldOwedChecks(events, cwd) {
|
|
2680
|
+
try {
|
|
2681
|
+
return foldOwedChecksOrThrow(events, cwd);
|
|
2682
|
+
}
|
|
2683
|
+
catch (error) {
|
|
2684
|
+
return { known: false, debt: "unknown", outstanding: [], acceptedRisk: [], discharged: [], unknown: [{ reason: `owed-check fold failed: ${String(error).split("\n")[0]}` }] };
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
function foldOwedChecksOrThrow(events, cwd) {
|
|
2688
|
+
const accepted = new Map();
|
|
2689
|
+
const discharged = new Set();
|
|
2690
|
+
const unknown = [];
|
|
2691
|
+
for (const e of effectiveEvents(events)) {
|
|
2692
|
+
if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE) {
|
|
2693
|
+
const o = e.data.obligation;
|
|
2694
|
+
if (isOwedCheck(o, e.taskId, e.data.gate))
|
|
2695
|
+
accepted.set(o.id, o);
|
|
2696
|
+
else {
|
|
2697
|
+
const recorded = typeof o === "object" && o !== null ? o.unknownReason : undefined;
|
|
2698
|
+
unknown.push({ taskId: e.taskId, gate: String(e.data.gate), reason: o === undefined ? "legacy waiver without an owed-check obligation"
|
|
2699
|
+
: typeof recorded === "string" ? recorded : "malformed owed-check obligation" });
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
else if (e.event === OWED_DISCHARGE_EVENT) {
|
|
2703
|
+
const ids = Array.isArray(e.data.ids) ? e.data.ids : [];
|
|
2704
|
+
const problems = ids.map((id) => {
|
|
2705
|
+
const o = accepted.get(String(id));
|
|
2706
|
+
return o ? dischargeProblem(e, o, events, cwd) : "discharge names no recorded obligation";
|
|
2707
|
+
});
|
|
2708
|
+
const problem = ids.length ? problems.find((p) => p !== undefined) : "discharge names no obligation";
|
|
2709
|
+
if (problem)
|
|
2710
|
+
unknown.push({ taskId: e.taskId, reason: problem });
|
|
2711
|
+
else
|
|
2712
|
+
for (const id of ids)
|
|
2713
|
+
discharged.add(String(id));
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
const acceptedRisk = [...accepted.values()];
|
|
2717
|
+
const outstanding = acceptedRisk.filter((o) => !discharged.has(o.id));
|
|
2718
|
+
const known = unknown.length === 0;
|
|
2719
|
+
return { known, debt: known ? outstanding.length : "unknown", outstanding, acceptedRisk, discharged: [...discharged], unknown };
|
|
2720
|
+
}
|
package/dist/run/lease.d.ts
CHANGED
|
@@ -33,7 +33,20 @@ export interface RepositoryLeaseOptions {
|
|
|
33
33
|
signal?: AbortSignal;
|
|
34
34
|
/** Called once per distinct holder the waiter is queued behind. */
|
|
35
35
|
onWait?: (holder: RepositoryLeaseHolder) => void;
|
|
36
|
+
/** The token a descendant inherited; defaults to REPOSITORY_LEASE_TOKEN_ENV. */
|
|
37
|
+
inherited?: string;
|
|
36
38
|
}
|
|
39
|
+
/** OBS-880/1071: the file lease's token, exported to the holder's descendants for the span it is held
|
|
40
|
+
* so a nested runner (a gate's suite under `tickmarkr verify`, a mutation child inside a leased suite)
|
|
41
|
+
* reenters instead of waiting on its own ancestor. Deliberately NOT COMMAND_LEASE_TOKEN_ENV: the
|
|
42
|
+
* installed daemon clears and rewrites that in-process token, and a command-lease token never names
|
|
43
|
+
* this file. Environment text alone proves nothing: see `reentrantHolder`. */
|
|
44
|
+
export declare const REPOSITORY_LEASE_TOKEN_ENV = "TICKMARKR_REPOSITORY_LEASE_TOKEN";
|
|
45
|
+
/** Reentry is identity-bound: the inherited token must name the record at THIS repository's lease path
|
|
46
|
+
* right now, its holder must be alive, and that holder must be a strict ancestor of this process. A
|
|
47
|
+
* forged, stale (released or superseded), foreign-repository or command-lease token matches no record;
|
|
48
|
+
* an in-process sibling is not a descendant — each of those waits like any other runner. */
|
|
49
|
+
export declare function reentrantHolder(path: string, inherited: string | undefined): Promise<RepositoryLeaseHolder | undefined>;
|
|
37
50
|
export declare function repositoryLeasePath(cwd: string): Promise<string>;
|
|
38
51
|
/** OBS-1057: dev+ino alone is not an identity across unlink/recreate — Linux hands the freed inode
|
|
39
52
|
* number straight to the next file, so a stale observation of a dead or corrupt reservation matched
|