@dzhechkov/harness-core 0.8.45 → 0.8.47
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/.dz-manifest.json +206 -174
- package/README.md +136 -8
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +32 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-snapshot.d.ts +14 -1
- package/dist/agentdb-snapshot.d.ts.map +1 -1
- package/dist/agentdb-snapshot.js +100 -4
- package/dist/agentdb-snapshot.js.map +1 -1
- package/dist/apply-leg.d.ts +9 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +47 -10
- package/dist/apply-leg.js.map +1 -1
- package/dist/architecture.d.ts +0 -5
- package/dist/architecture.d.ts.map +1 -1
- package/dist/architecture.js +25 -2
- package/dist/architecture.js.map +1 -1
- package/dist/codex-rollouts.d.ts +35 -7
- package/dist/codex-rollouts.d.ts.map +1 -1
- package/dist/codex-rollouts.js +201 -113
- package/dist/codex-rollouts.js.map +1 -1
- package/dist/cost-ledger.d.ts +42 -3
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +478 -15
- package/dist/cost-ledger.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +16 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +21 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +48 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-plan.d.ts +4 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +4 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/mutation-gate.d.ts +36 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +109 -19
- package/dist/mutation-gate.js.map +1 -1
- package/dist/npm-homepage.d.ts +64 -0
- package/dist/npm-homepage.d.ts.map +1 -0
- package/dist/npm-homepage.js +109 -0
- package/dist/npm-homepage.js.map +1 -0
- package/dist/operations.d.ts +6 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +150 -24
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +13 -0
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +28 -2
- package/dist/pack-inventory.js.map +1 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +4 -1
- package/dist/parity.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +43 -1
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +269 -13
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/publish.d.ts +5 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +18 -1
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +63 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +82 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release-package-audit.d.ts +86 -0
- package/dist/release-package-audit.d.ts.map +1 -0
- package/dist/release-package-audit.js +272 -0
- package/dist/release-package-audit.js.map +1 -0
- package/dist/release.d.ts +8 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +58 -32
- package/dist/release.js.map +1 -1
- package/dist/round-exec-claim.d.ts +36 -0
- package/dist/round-exec-claim.d.ts.map +1 -0
- package/dist/round-exec-claim.js +17 -0
- package/dist/round-exec-claim.js.map +1 -0
- package/dist/round.d.ts +10 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +5 -1
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +11 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +124 -20
- package/dist/run-records.js.map +1 -1
- package/dist/session-retro.d.ts +33 -0
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +170 -34
- package/dist/session-retro.js.map +1 -1
- package/dist/sign.d.ts +44 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +126 -1
- package/dist/sign.js.map +1 -1
- package/dist/stage-usage.d.ts +148 -0
- package/dist/stage-usage.d.ts.map +1 -0
- package/dist/stage-usage.js +261 -0
- package/dist/stage-usage.js.map +1 -0
- package/dist/statusline.d.ts +23 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +167 -1
- package/dist/statusline.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +21 -19
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +206 -95
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +12 -3
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +29 -2
- package/dist/workflow-run.js.map +1 -1
- package/package.json +4 -4
- package/sbom.json +309 -229
- package/src/agentdb-index.ts +31 -2
- package/src/agentdb-snapshot.ts +105 -4
- package/src/apply-leg.ts +47 -10
- package/src/architecture.ts +15 -2
- package/src/codex-rollouts.ts +155 -147
- package/src/cost-ledger.ts +308 -19
- package/src/feature-adr-routing.ts +22 -0
- package/src/guard.ts +52 -0
- package/src/index.ts +19 -3
- package/src/loop-plan.ts +8 -0
- package/src/mutation-gate.ts +153 -19
- package/src/npm-homepage.ts +142 -0
- package/src/operations.ts +177 -22
- package/src/pack-inventory.ts +27 -2
- package/src/parity.ts +4 -1
- package/src/publish-sibling-drift.ts +214 -11
- package/src/publish.ts +20 -1
- package/src/qe-bridge.ts +86 -1
- package/src/release-package-audit.ts +264 -0
- package/src/release.ts +55 -22
- package/src/round-exec-claim.ts +43 -0
- package/src/round.ts +8 -0
- package/src/run-records.ts +101 -22
- package/src/session-retro.ts +153 -31
- package/src/sign.ts +136 -1
- package/src/stage-usage.ts +191 -0
- package/src/statusline.ts +149 -1
- package/src/workflow-run-dispatch.ts +148 -89
- package/src/workflow-run.ts +40 -4
- package/dist/ledger-cost-fill.d.ts +0 -58
- package/dist/ledger-cost-fill.d.ts.map +0 -1
- package/dist/ledger-cost-fill.js +0 -78
- package/dist/ledger-cost-fill.js.map +0 -1
- package/dist/retro.d.ts +0 -131
- package/dist/retro.d.ts.map +0 -1
- package/dist/retro.js +0 -207
- package/dist/retro.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -120
- package/dist/sbom.js.map +0 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* round-exec-claim-takeover (FR-1): the pure verdict behind `dz round exec`'s claim transaction.
|
|
3
|
+
*
|
|
4
|
+
* A round claimed by an `exec` whose process died (SIGTERM, OOM, reboot) used to refuse every later
|
|
5
|
+
* `exec` forever (backlog 280e914607397474). This decides — from facts the caller measured under the
|
|
6
|
+
* state lock — whether the standing claim is held, provably stale-dead, or unknowable:
|
|
7
|
+
*
|
|
8
|
+
* - `pidAlive === true` ⇒ `held`, regardless of age (a live owner is never taken over);
|
|
9
|
+
* - `pidAlive === null` ⇒ `unknown` (the probe was inconclusive — refusal is the safe side);
|
|
10
|
+
* - `pid` not a positive safe integer ⇒ `unknown` («no pid recorded») — nothing to probe;
|
|
11
|
+
* - `execClaimedAt` unparsable ⇒ `unknown` («claim time unreadable») — no age to debounce on;
|
|
12
|
+
* - `pidAlive === false` and age ≥ `staleMinutes` ⇒ `stale-dead` with the floored age;
|
|
13
|
+
* - `pidAlive === false` but younger ⇒ `held` — a just-died claim may be a restart in flight, and
|
|
14
|
+
* the threshold is the debounce.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no clock, no process table — `now` and `pidAlive` are inputs so the caller keeps the
|
|
17
|
+
* critical section short (`process.kill(pid, 0)`, NFR-1) and tests need no real processes.
|
|
18
|
+
*/
|
|
19
|
+
export type ExecClaimVerdict =
|
|
20
|
+
| { kind: 'held' }
|
|
21
|
+
| { kind: 'stale-dead'; ageMinutes: number }
|
|
22
|
+
| { kind: 'unknown'; reason: string };
|
|
23
|
+
|
|
24
|
+
export type ExecClaimTakeoverInput = {
|
|
25
|
+
readonly execClaimedAt?: string | undefined;
|
|
26
|
+
readonly pid?: number | undefined;
|
|
27
|
+
readonly pidAlive: boolean | null;
|
|
28
|
+
readonly now: number;
|
|
29
|
+
readonly staleMinutes: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function decideExecClaimTakeover(input: ExecClaimTakeoverInput): ExecClaimVerdict {
|
|
33
|
+
if (input.pidAlive === true) return { kind: 'held' };
|
|
34
|
+
if (input.pid === undefined || !Number.isSafeInteger(input.pid) || input.pid <= 0) {
|
|
35
|
+
return { kind: 'unknown', reason: 'no pid recorded' };
|
|
36
|
+
}
|
|
37
|
+
if (input.pidAlive === null) return { kind: 'unknown', reason: 'PID probe inconclusive' };
|
|
38
|
+
const claimedMs = input.execClaimedAt === undefined ? Number.NaN : Date.parse(input.execClaimedAt);
|
|
39
|
+
if (!Number.isFinite(claimedMs)) return { kind: 'unknown', reason: 'claim time unreadable' };
|
|
40
|
+
const ageMinutes = Math.floor((input.now - claimedMs) / 60_000);
|
|
41
|
+
if (ageMinutes >= input.staleMinutes) return { kind: 'stale-dead', ageMinutes };
|
|
42
|
+
return { kind: 'held' };
|
|
43
|
+
}
|
package/src/round.ts
CHANGED
|
@@ -102,6 +102,8 @@ export interface RoundLedgerRow {
|
|
|
102
102
|
* fix-round-1 #1/#2 (ADR-001 п.2 amended): also present when an EXPLICIT `--reviewer` AGREES with
|
|
103
103
|
* the sidecar's own `gradedBy` (`reviewSource:'flag+qe-bridge'` below names that case). */
|
|
104
104
|
readonly reviewMinutes?: number;
|
|
105
|
+
readonly reviewIdentitySource?: 'round-run-task' | 'partial-identity' | 'legacy-window';
|
|
106
|
+
readonly reviewIdentity?: { readonly round: number | null; readonly roundRun: string | null; readonly taskId: string | null; readonly bridgeRunId: string | null };
|
|
105
107
|
/** measurement-integrity FR-7: present ONLY alongside `reviewMinutes` — names where `reviewer` and
|
|
106
108
|
* `reviewMinutes` came from, so a reader never confuses a sidecar-sourced figure for a flag.
|
|
107
109
|
* review-cost-ledger fix-round-1 #1/#2 (ADR-001 п.2 amended): `'qe-bridge'` when `reviewer` was
|
|
@@ -188,6 +190,8 @@ export interface RoundLedgerRow {
|
|
|
188
190
|
* `closeRound` never opens a file.
|
|
189
191
|
*/
|
|
190
192
|
export interface RoundReviewSidecar {
|
|
193
|
+
readonly reviewIdentitySource?: RoundLedgerRow['reviewIdentitySource'];
|
|
194
|
+
readonly reviewIdentity?: RoundLedgerRow['reviewIdentity'];
|
|
191
195
|
/** Who graded it — family + model, e.g. `codex:gpt-5.6-sol`. */
|
|
192
196
|
readonly gradedBy: string;
|
|
193
197
|
readonly elapsedMs: number;
|
|
@@ -609,6 +613,8 @@ export function closeRound(input: {
|
|
|
609
613
|
...(nonEmpty(input.stateId) ? { stateId: input.stateId } : {}),
|
|
610
614
|
...(input.state.envelope !== undefined ? { envelope: input.state.envelope } : {}),
|
|
611
615
|
...(reviewSource !== null ? { reviewSource } : {}),
|
|
616
|
+
...(reviewerTiedToSidecar && input.reviewSidecar?.reviewIdentitySource !== undefined ? { reviewIdentitySource: input.reviewSidecar.reviewIdentitySource } : {}),
|
|
617
|
+
...(reviewerTiedToSidecar && input.reviewSidecar?.reviewIdentity !== undefined ? { reviewIdentity: input.reviewSidecar.reviewIdentity } : {}),
|
|
612
618
|
...(reviewMinutes !== null ? { reviewMinutes } : {}),
|
|
613
619
|
taskId,
|
|
614
620
|
...(taskIdSource !== null ? { taskIdSource } : {}),
|
|
@@ -700,11 +706,13 @@ export function readOpenRoundTaskId(
|
|
|
700
706
|
states: readonly RoundState[],
|
|
701
707
|
slug: string,
|
|
702
708
|
unreadableStateCount = 0,
|
|
709
|
+
authorityReadError = false,
|
|
703
710
|
): {
|
|
704
711
|
readonly taskId: string | null;
|
|
705
712
|
readonly source: 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
|
|
706
713
|
} {
|
|
707
714
|
const matches = states.filter((s) => s.slug === slug);
|
|
715
|
+
if (authorityReadError) return { taskId: null, source: 'unavailable' };
|
|
708
716
|
const unreadable = Number.isFinite(unreadableStateCount) && unreadableStateCount > 0 ? Math.floor(unreadableStateCount) : 0;
|
|
709
717
|
if (unreadable > 0 && matches.length === 0) return { taskId: null, source: 'unavailable' };
|
|
710
718
|
if (unreadable > 0) return { taskId: null, source: 'ambiguous' };
|
package/src/run-records.ts
CHANGED
|
@@ -13,11 +13,28 @@
|
|
|
13
13
|
* Pure: payload in, verdict out. The CLI owns paths, the append, the read-back and the exit code.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
16
17
|
import { matchCodexRollouts } from './codex-rollouts.js';
|
|
17
18
|
import type { CodexRollout } from './codex-rollouts.js';
|
|
18
|
-
import { redactTrainingPayload } from './feature-adr-checkpoints.js';
|
|
19
|
+
import { fnv1a64, redactTrainingPayload } from './feature-adr-checkpoints.js';
|
|
19
20
|
import { validateExperimentEnvelope } from './feature-adr-envelope.js';
|
|
20
21
|
|
|
22
|
+
// Capture contract: ordered named fields, with absent distinct from explicit null.
|
|
23
|
+
// Kept private at producer/reader boundaries; both use this exact sha256 representation.
|
|
24
|
+
function capturedPayload(evidence: Record<string, unknown>): string {
|
|
25
|
+
const value = (v: unknown) => v === undefined ? { absent: true } : v;
|
|
26
|
+
const receipts = Array.isArray(evidence['receipts']) ? evidence['receipts'] : [];
|
|
27
|
+
return JSON.stringify([
|
|
28
|
+
...['schema', 'sessionId', 'turnId', 'sourcePath', 'matchBasis', 'capturedFrom', 'capturedTo', 'model', 'cwd', 'reportedTotalBasis', 'inputCacheSemantics'].map((k) => [k, value(evidence[k])]),
|
|
29
|
+
['owner', ...['runId', 'taskId', 'stage', 'attempt', 'role'].map((k) => [k, value((evidence['owner'] as Record<string, unknown> | undefined)?.[k])])],
|
|
30
|
+
receipts.map((raw) => {
|
|
31
|
+
const r = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}; const t = r['totals'] as Record<string, unknown> | undefined;
|
|
32
|
+
return [...['key', 'responseId', 'turnId', 'turnIndex', 'timestamp', 'payloadDigest', 'source'].map((k) => [k, value(r[k])]),
|
|
33
|
+
['totals', ...['input', 'output', 'cachedInput', 'cachedWrite', 'reasoning', 'total'].map((k) => [k, value(t?.[k])])]];
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/** Structural — a caller passes `cost-scoring.ts`'s `ModelPricing`; kept local so `run-records.ts`
|
|
22
39
|
* does not have to import `cost-scoring.ts` just to name a type.
|
|
23
40
|
*
|
|
@@ -86,6 +103,44 @@ export function parseModelSpec(spec: unknown): ParsedModelSpec | null {
|
|
|
86
103
|
return null;
|
|
87
104
|
}
|
|
88
105
|
|
|
106
|
+
/** Internal shared ledger authority resolver; not a package-public barrel API.
|
|
107
|
+
* Capture/source values are comparison targets and never participate in claimant resolution. */
|
|
108
|
+
export function resolveLedgerModelProvenance(row: Readonly<Record<string, unknown>>) {
|
|
109
|
+
const diagnostics: string[] = [];
|
|
110
|
+
const canonicalFamily = (v: string) => v === 'openai' ? 'codex' : v;
|
|
111
|
+
const direct = typeof row['model'] === 'string' && row['model'].length <= 128 && /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/.test(row['model']) ? row['model'] : null;
|
|
112
|
+
if (row['model'] != null && direct === null) diagnostics.push('model-invalid-direct');
|
|
113
|
+
const family = typeof row['family'] === 'string' && SPEC_PART.test(row['family']) ? row['family'] : null;
|
|
114
|
+
if (row['family'] != null && family === null) diagnostics.push('model-invalid-family');
|
|
115
|
+
const specs: ParsedModelSpec[] = []; const familyHints: string[] = [];
|
|
116
|
+
for (const key of ['coder', 'reviewer']) {
|
|
117
|
+
const raw = row[key]; if (raw == null) continue;
|
|
118
|
+
if (raw === 'codex' || raw === 'claude') { familyHints.push(raw); continue; }
|
|
119
|
+
const spec = parseModelSpec(raw);
|
|
120
|
+
const parts = typeof raw === 'string' ? raw.trim().split(':') : [];
|
|
121
|
+
if (!spec || !SPEC_PART.test(spec.model) || (parts[0] === 'claude' && parts.length !== 2)) {
|
|
122
|
+
diagnostics.push('model-invalid-executor:' + key); continue;
|
|
123
|
+
}
|
|
124
|
+
specs.push(spec); familyHints.push(spec.family);
|
|
125
|
+
}
|
|
126
|
+
const models = new Set(specs.map((v) => v.model)); const families = new Set(familyHints);
|
|
127
|
+
if (models.size > 1 || families.size > 1) diagnostics.push('model-executor-conflict');
|
|
128
|
+
const unique = models.size === 1 ? specs[0]! : null;
|
|
129
|
+
if (direct !== null && unique && direct !== unique.model) diagnostics.push('model-direct-executor-conflict');
|
|
130
|
+
if (family !== null && [...families].some((v) => canonicalFamily(family) !== v)) diagnostics.push('model-family-conflict');
|
|
131
|
+
const declaredProvenance = row['modelProvenance'];
|
|
132
|
+
const validProvenance = new Set(['caller-recorded', 'executor-spec', 'provider-reported', 'dispatcher-reported', 'probed-request', 'not-recorded']);
|
|
133
|
+
if (declaredProvenance != null && (typeof declaredProvenance !== 'string' || !validProvenance.has(declaredProvenance))) diagnostics.push('model-invalid-provenance');
|
|
134
|
+
if (declaredProvenance === 'executor-spec' && (!unique || (direct !== null && direct !== unique.model))) diagnostics.push('model-provenance-conflict');
|
|
135
|
+
if (declaredProvenance != null && declaredProvenance !== 'executor-spec' && declaredProvenance !== 'not-recorded' && direct === null) diagnostics.push('model-provenance-conflict');
|
|
136
|
+
const conflict = diagnostics.some((d) => /invalid|conflict/.test(d));
|
|
137
|
+
const model = conflict ? null : direct ?? unique?.model ?? null;
|
|
138
|
+
if (model === null && !conflict) diagnostics.push('model-not-recorded');
|
|
139
|
+
const resolvedFamily = family ?? (families.size === 1 ? [...families][0]! : null);
|
|
140
|
+
return { model, family: resolvedFamily, modelProvenance: model === null ? 'not-recorded' : direct !== null
|
|
141
|
+
? typeof declaredProvenance === 'string' ? declaredProvenance : 'caller-recorded' : 'executor-spec', diagnostics };
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
/** measurement-integrity FR-5/FR-6: enrichment the WRITER supplies at write time — the rollout logs
|
|
90
145
|
* it already read (I/O lives in the CLI; this stays pure) and the price table snapshot. Absent
|
|
91
146
|
* entirely ⇒ zero behavior change from before this feature (NFR-1). */
|
|
@@ -93,6 +148,9 @@ export interface LedgerEnrichInput {
|
|
|
93
148
|
/** Parsed Codex rollout logs for the window the CLI read — usually every rollout from the days the
|
|
94
149
|
* window spans. Pure data; the CLI is the one that walked `~/.codex/sessions`. */
|
|
95
150
|
readonly rollouts?: readonly CodexRollout[];
|
|
151
|
+
readonly rolloutId?: string;
|
|
152
|
+
readonly turnId?: string;
|
|
153
|
+
readonly discoveryDiagnostics?: readonly string[];
|
|
96
154
|
/** The stage's own time window — usually [the previous ledger row's `ts`, this write's `ts`], or
|
|
97
155
|
* an explicit `--window-from/--window-to`. Omitted ⇒ no rollout match is even attempted. */
|
|
98
156
|
readonly window?: { readonly from: string; readonly to: string };
|
|
@@ -492,32 +550,29 @@ export function decideRecordWrite(input: {
|
|
|
492
550
|
// one that already has a token figure, is left untouched. The loose `/codex/i` check below only
|
|
493
551
|
// decides whether this row is WORTH TRYING at all.
|
|
494
552
|
const tokensIsNull = stamped['tokens'] === null;
|
|
495
|
-
const looksCodexFamily = isCodexFamily(stamped['coder']) || isCodexFamily(stamped['reviewer']);
|
|
496
|
-
if (
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const codexModels = new Set(
|
|
505
|
-
[parseModelSpec(stamped['coder']), parseModelSpec(stamped['reviewer'])]
|
|
506
|
-
.filter((s): s is ParsedModelSpec => s !== null && s.family === 'codex')
|
|
507
|
-
.map((s) => s.model),
|
|
508
|
-
);
|
|
509
|
-
if (codexModels.size !== 1) {
|
|
553
|
+
const looksCodexFamily = isCodexFamily(stamped['coder']) || isCodexFamily(stamped['reviewer']) || stamped['family'] === 'codex' || stamped['family'] === 'openai';
|
|
554
|
+
if (enrich.discoveryDiagnostics?.length) {
|
|
555
|
+
stamped['usageDiagnostics'] = enrich.discoveryDiagnostics;
|
|
556
|
+
stamped['tokensSource'] = 'codex-rollout:source-discovery-unavailable';
|
|
557
|
+
} else if ((tokensIsNull || enrich.rolloutId !== undefined || enrich.turnId !== undefined) && looksCodexFamily) {
|
|
558
|
+
// Use the same authority contract as the readonly source comparison and normalized report.
|
|
559
|
+
// A direct model or unique agreeing executor specs supply authority; a source never does.
|
|
560
|
+
const identity = resolveLedgerModelProvenance(stamped);
|
|
561
|
+
if (identity.model === null || !['codex', 'openai'].includes(identity.family ?? '')) {
|
|
510
562
|
stamped['tokensSource'] = 'codex-rollout:no-model';
|
|
511
|
-
|
|
512
|
-
|
|
563
|
+
stamped['usageDiagnostics'] = [...(Array.isArray(stamped['usageDiagnostics']) ? stamped['usageDiagnostics'] : []), ...identity.diagnostics];
|
|
564
|
+
} else if (enrich.window !== undefined || enrich.rolloutId !== undefined || enrich.turnId !== undefined) {
|
|
565
|
+
const model = identity.model;
|
|
513
566
|
const match = matchCodexRollouts(enrich.rollouts ?? [], {
|
|
514
|
-
from: enrich.window.from,
|
|
515
|
-
to: enrich.window.to,
|
|
567
|
+
...(enrich.window !== undefined ? { from: enrich.window.from, to: enrich.window.to } : {}),
|
|
516
568
|
model,
|
|
569
|
+
...(enrich.rolloutId !== undefined ? { rolloutId: enrich.rolloutId } : {}),
|
|
570
|
+
...(enrich.turnId !== undefined ? { turnId: enrich.turnId } : {}),
|
|
517
571
|
...(enrich.cwd !== undefined ? { cwd: enrich.cwd } : {}),
|
|
518
572
|
});
|
|
519
573
|
if (match.status === 'one') {
|
|
520
|
-
stamped['tokens'] = match.rollout.totals.total;
|
|
574
|
+
if (tokensIsNull || stamped['tokens'] === undefined) stamped['tokens'] = match.rollout.totals.total;
|
|
575
|
+
else if (stamped['tokens'] !== match.rollout.totals.total) stamped['usageDiagnostics'] = ['caller-source-total-mismatch'];
|
|
521
576
|
const startMs = match.rollout.startedAt !== null ? Date.parse(match.rollout.startedAt) : NaN;
|
|
522
577
|
const endMs = match.rollout.endedAt !== null ? Date.parse(match.rollout.endedAt) : NaN;
|
|
523
578
|
// measurement-integrity fix-round-1/F6 (Codex r1 HIGH #6): fill-ONLY-null — an existing
|
|
@@ -528,9 +583,26 @@ export function decideRecordWrite(input: {
|
|
|
528
583
|
}
|
|
529
584
|
stamped['tokensSource'] = 'codex-rollout';
|
|
530
585
|
stamped['rolloutId'] = match.rollout.id;
|
|
586
|
+
const found = match.rollout;
|
|
587
|
+
for (const [key, value] of Object.entries({ tokensTotal: found.totals.total, tokensIn: found.totals.input,
|
|
588
|
+
tokensOut: found.totals.output, tokensCacheRead: found.totals.cachedInput, tokensCacheWrite: found.totals.cachedWrite ?? null,
|
|
589
|
+
tokensReasoning: found.totals.reasoning, reportedTotalBasis: 'raw-inclusive', inputCacheSemantics: 'includes-cache-read-write',
|
|
590
|
+
usageDiagnostics: found.diagnostics ?? [] })) if (stamped[key] === undefined || stamped[key] === null) stamped[key] = value;
|
|
591
|
+
const receipts = (found.receipts ?? []).map((receipt) => ({ ...receipt, key: JSON.stringify([found.id, receipt.key]) }));
|
|
592
|
+
const usageEvidence: Record<string, unknown> = { schema: 'codex-rollout-scope-1', sessionId: found.id, turnId: found.turnId ?? null,
|
|
593
|
+
sourcePath: found.sourcePath ?? null, matchBasis: enrich.rolloutId !== undefined || enrich.turnId !== undefined ? 'exact' : 'legacy-window',
|
|
594
|
+
capturedFrom: found.startedAt, capturedTo: found.endedAt, receipts, model: found.model, cwd: found.cwd,
|
|
595
|
+
owner: Object.fromEntries(['runId', 'taskId', 'stage', 'attempt', 'role'].map((key) => [key, stamped[key] ?? null])),
|
|
596
|
+
reportedTotalBasis: 'raw-inclusive', inputCacheSemantics: 'includes-cache-read-write',
|
|
597
|
+
payloadDigest: fnv1a64(JSON.stringify(receipts.map((r) => [r.key, r.payloadDigest]))) };
|
|
598
|
+
usageEvidence['captureSha256'] = createHash('sha256').update(capturedPayload(usageEvidence)).digest('hex');
|
|
599
|
+
if (JSON.stringify(usageEvidence).length <= 16000) {
|
|
600
|
+
if (stamped['usageEvidence'] === undefined || stamped['usageEvidence'] === null) stamped['usageEvidence'] = usageEvidence;
|
|
601
|
+
} else stamped['usageDiagnostics'] = [...(Array.isArray(stamped['usageDiagnostics']) ? stamped['usageDiagnostics'] : []), 'source-scope-over-record-limit'];
|
|
602
|
+
|
|
531
603
|
} else {
|
|
532
604
|
// `none` or `ambiguous` — NFR-3: an explicit status, never a guessed number.
|
|
533
|
-
stamped['tokensSource'] = `codex-rollout:${match.status}`;
|
|
605
|
+
stamped['tokensSource'] = `codex-rollout:${enrich.rolloutId !== undefined || enrich.turnId !== undefined ? 'exact-' : ''}${match.status}`;
|
|
534
606
|
}
|
|
535
607
|
} else {
|
|
536
608
|
// Eligible in principle (codex family, one reliable model, tokens null) but no window was
|
|
@@ -544,6 +616,7 @@ export function decideRecordWrite(input: {
|
|
|
544
616
|
// above (a Claude row gets priced too; only tokens enrichment is codex-specific).
|
|
545
617
|
if (enrich.prices !== undefined) {
|
|
546
618
|
const modelIds = new Set<string>();
|
|
619
|
+
if (typeof stamped['model'] === 'string' && stamped['model'].trim()) modelIds.add(stamped['model'].trim());
|
|
547
620
|
for (const v of [stamped['coder'], stamped['reviewer']]) {
|
|
548
621
|
if (typeof v === 'string' && v.trim() !== '') modelIds.add(v.trim());
|
|
549
622
|
}
|
|
@@ -566,7 +639,13 @@ export function decideRecordWrite(input: {
|
|
|
566
639
|
if (price === null) unknown.push(modelId);
|
|
567
640
|
else table[modelId] = { prompt: price.prompt, completion: price.completion, cachedInput: price.cachedInput, cacheCreation: price.cacheCreation };
|
|
568
641
|
}
|
|
642
|
+
const matches = Object.fromEntries([...modelIds].map((id) => {
|
|
643
|
+
const parsed = parseModelSpec(id); const normalized = parsed?.family === 'claude' ? 'claude-' + parsed.model : parsed?.model ?? id;
|
|
644
|
+
const key = Object.keys(enrich.prices ?? {}).filter((k) => normalized.toLowerCase().startsWith(k)).sort((a,b) => b.length-a.length)[0] ?? null;
|
|
645
|
+
return [id, { tableKey: key, kind: key !== null && key === normalized && !key.includes('claude') ? 'exact' : key !== null ? 'family-estimate' : 'unknown' }];
|
|
646
|
+
}));
|
|
569
647
|
const computedPrices = {
|
|
648
|
+
matches,
|
|
570
649
|
snapshotAt: input.timestamp ?? null,
|
|
571
650
|
table,
|
|
572
651
|
...(unknown.length > 0 ? { unknown } : {}),
|
package/src/session-retro.ts
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* is taught silently but NOT drilled — no nagging on a one-off. Drills are for recurrent patterns only.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
|
|
18
18
|
import { join, basename, dirname } from 'node:path';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
20
21
|
|
|
21
22
|
import { withProjectLockSync, NamedLockTimeoutError } from './named-lock.js';
|
|
22
23
|
|
|
@@ -538,11 +539,62 @@ export function findLatestTranscript(repoRoot: string): string | null {
|
|
|
538
539
|
// ── Per-turn admission-debt scan (feature narrated-error-must-be-taught, ADR-001 D2/D4) ────────────
|
|
539
540
|
// The Stop hook runs `dz retro --scan-tail` after EVERY assistant turn, so this half is built around
|
|
540
541
|
// one budget: O(new bytes) — a persisted byte offset, no full re-read, no store open, no subprocess.
|
|
541
|
-
// The sentinel `.dz/retro
|
|
542
|
-
// directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4)
|
|
542
|
+
// The sentinel `.dz/retro/<session>/pending.json` is the debt; the recall hook turns it into a
|
|
543
|
+
// next-prompt directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4)
|
|
544
|
+
// clears it.
|
|
543
545
|
|
|
546
|
+
/** LEGACY flat names (pre retro-debt-sentinel-per-session): the ONE pair every session in a worktree
|
|
547
|
+
* once shared under `.dz/`. A scan ADOPTS its own transcript's flat files once (FR-4) and never
|
|
548
|
+
* writes them again; a stranger's flat file is never touched. */
|
|
544
549
|
export const RETRO_SCAN_STATE_FILE = 'retro-scan-state.json';
|
|
545
550
|
export const RETRO_PENDING_FILE = 'retro-pending.json';
|
|
551
|
+
|
|
552
|
+
// ── Per-session layout (feature retro-debt-sentinel-per-session, FR-1; backlog 58f3c56fbb9d6893) ──
|
|
553
|
+
// Two sessions in one worktree shared the flat pair above, so a Stop scan of EITHER unlinked the
|
|
554
|
+
// other's LIVE debt as "foreign", and the shared bookmark made each session's offset meaningless to
|
|
555
|
+
// the other (foreign transcript ⇒ offset 0 ⇒ a bounded re-scan every turn; MEASURED 22.09 20:23, a
|
|
556
|
+
// second session in this repo). The pair now lives in a directory named after the session.
|
|
557
|
+
export const RETRO_SESSION_DIRNAME = 'retro';
|
|
558
|
+
export const RETRO_SESSION_PENDING_BASENAME = 'pending.json';
|
|
559
|
+
export const RETRO_SESSION_STATE_BASENAME = 'scan-state.json';
|
|
560
|
+
/** A session dir whose scan-state is older than this belongs to a dead session: the next scan of
|
|
561
|
+
* any other session sweeps it (FR-5). A live session that ran no Stop for 7 days loses only a debt
|
|
562
|
+
* the hook would have called stale anyway (SENTINEL_FRESH_MS is 30 min) — accepted risk R2. */
|
|
563
|
+
export const RETRO_SESSION_STALE_MS = 7 * 24 * 3600 * 1000;
|
|
564
|
+
/** At most this many `.dz/retro/*` entries are examined per scan — the sweep's cost bound (NFR-1). */
|
|
565
|
+
const RETRO_SWEEP_MAX_ENTRIES = 64;
|
|
566
|
+
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The directory name for a session id. PURE. A plain token is used as is; anything else — a
|
|
570
|
+
* path-like string, the empty string, an over-long id, and the two dot names the charset regex
|
|
571
|
+
* alone would let through (`.` ⇒ `<dzDir>/retro`, `..` ⇒ `<dzDir>` itself) — becomes the first
|
|
572
|
+
* 32 hex of its sha256, so a session id can never name a path outside `.dz/retro/` (AC-2).
|
|
573
|
+
* TWIN: the recall hook in `apply-leg.ts` carries a copy (the hook must stay dependency-free);
|
|
574
|
+
* `test/retro-sentinel-per-session.test.ts` pins the two to equal outputs.
|
|
575
|
+
*/
|
|
576
|
+
export function safeSessionDirName(sessionId: string): string {
|
|
577
|
+
if (SAFE_SESSION_ID_RE.test(sessionId) && sessionId !== '.' && sessionId !== '..') return sessionId;
|
|
578
|
+
return createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The session id the SCANNER derives: the transcript basename without `.jsonl`. Claude Code names
|
|
582
|
+
* the transcript after the session id, so this equals the `session_id` the hook payload carries. PURE. */
|
|
583
|
+
export function sessionIdFromTranscript(transcriptPath: string): string {
|
|
584
|
+
return basename(transcriptPath).replace(/\.jsonl$/, '');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export interface RetroSessionPaths {
|
|
588
|
+
readonly dir: string;
|
|
589
|
+
readonly pendingPath: string;
|
|
590
|
+
readonly statePath: string;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Where ONE session's debt and bookmark live: `<dzDir>/retro/<safeId>/{pending,scan-state}.json`. PURE, no fs. */
|
|
594
|
+
export function retroSessionPaths(dzDir: string, sessionId: string): RetroSessionPaths {
|
|
595
|
+
const dir = join(dzDir, RETRO_SESSION_DIRNAME, safeSessionDirName(sessionId));
|
|
596
|
+
return { dir, pendingPath: join(dir, RETRO_SESSION_PENDING_BASENAME), statePath: join(dir, RETRO_SESSION_STATE_BASENAME) };
|
|
597
|
+
}
|
|
546
598
|
/** Bound the very FIRST scan of an already-huge transcript; later scans read only the new bytes. */
|
|
547
599
|
const MAX_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
|
|
548
600
|
/** Without a session id to compare, a sentinel older than this is stale (fallback freshness only). */
|
|
@@ -628,6 +680,10 @@ export interface TailScanOutcome {
|
|
|
628
680
|
readonly snippet?: string;
|
|
629
681
|
readonly scannedBytes: number;
|
|
630
682
|
readonly offset: number;
|
|
683
|
+
/** The session the scan was scoped to (FR-6) — absent only when no transcript was named. */
|
|
684
|
+
readonly sessionId?: string;
|
|
685
|
+
/** The per-session sentinel path the scan wrote, cleared, or left absent (FR-6). */
|
|
686
|
+
readonly pendingPath?: string;
|
|
631
687
|
}
|
|
632
688
|
|
|
633
689
|
/** The scan-state + sentinel pair is a read-modify-write store; per the repo concurrency rule
|
|
@@ -728,6 +784,8 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
|
|
|
728
784
|
*/
|
|
729
785
|
export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
|
|
730
786
|
if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
|
|
787
|
+
const sessionId = sessionIdFromTranscript(transcriptPath);
|
|
788
|
+
const ids = { sessionId, pendingPath: retroSessionPaths(dzDir, sessionId).pendingPath };
|
|
731
789
|
try {
|
|
732
790
|
return withProjectLockSync(
|
|
733
791
|
dirname(dzDir),
|
|
@@ -736,43 +794,71 @@ export function runRetroTailScan(dzDir: string, transcriptPath: string | null, n
|
|
|
736
794
|
{ timeoutMs: RETRO_SCAN_LOCK_TIMEOUT_MS },
|
|
737
795
|
);
|
|
738
796
|
} catch (e) {
|
|
739
|
-
if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0 };
|
|
797
|
+
if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0, ...ids };
|
|
740
798
|
// Compromised lock or any unexpected failure: report nothing, advance nothing (never-block).
|
|
741
|
-
return { status: 'none', scannedBytes: 0, offset: 0 };
|
|
799
|
+
return { status: 'none', scannedBytes: 0, offset: 0, ...ids };
|
|
742
800
|
}
|
|
743
801
|
}
|
|
744
802
|
|
|
745
803
|
/** The transaction body — call ONLY under the named lock. Never throws for ordinary fs failures. */
|
|
746
804
|
function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: string): TailScanOutcome {
|
|
805
|
+
// FR-2: every path this scan writes or removes is inside ITS OWN session dir; the only files it
|
|
806
|
+
// ever touches outside are the legacy flat pair, and only to adopt its own (FR-4).
|
|
807
|
+
const sessionId = sessionIdFromTranscript(transcriptPath);
|
|
808
|
+
const { dir, pendingPath, statePath } = retroSessionPaths(dzDir, sessionId);
|
|
809
|
+
const ids = { sessionId, pendingPath };
|
|
747
810
|
try {
|
|
748
|
-
const
|
|
749
|
-
const
|
|
811
|
+
const legacyStatePath = join(dzDir, RETRO_SCAN_STATE_FILE);
|
|
812
|
+
const legacyPendingPath = join(dzDir, RETRO_PENDING_FILE);
|
|
750
813
|
|
|
814
|
+
const readOffset = (p: string): number | null => {
|
|
815
|
+
try {
|
|
816
|
+
const st = JSON.parse(readFileSync(p, 'utf8')) as { transcript?: string; offset?: number };
|
|
817
|
+
if (st.transcript === transcriptPath && typeof st.offset === 'number' && Number.isFinite(st.offset) && st.offset >= 0) return Math.floor(st.offset);
|
|
818
|
+
} catch { /* absent or unreadable */ }
|
|
819
|
+
return null;
|
|
820
|
+
};
|
|
751
821
|
let offset = 0;
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
822
|
+
let adoptLegacyState = false;
|
|
823
|
+
const ownOffset = readOffset(statePath);
|
|
824
|
+
if (ownOffset !== null) offset = ownOffset;
|
|
825
|
+
else if (!existsSync(statePath)) {
|
|
826
|
+
// FR-4: the flat bookmark is adopted ONCE — only while this session has no bookmark of its own,
|
|
827
|
+
// and only when it names THIS transcript. Another session's stays where it is.
|
|
828
|
+
const legacyOffset = readOffset(legacyStatePath);
|
|
829
|
+
if (legacyOffset !== null) { offset = legacyOffset; adoptLegacyState = true; }
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const readSentinel = (p: string): { prior: AdmissionDebt | null; present: boolean } => {
|
|
833
|
+
try {
|
|
834
|
+
const s = JSON.parse(readFileSync(p, 'utf8')) as Partial<RetroPendingSentinel>;
|
|
835
|
+
if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
|
|
836
|
+
return { prior: { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) }, present: true };
|
|
837
|
+
}
|
|
838
|
+
return { prior: null, present: true };
|
|
839
|
+
} catch { return { prior: null, present: false }; }
|
|
840
|
+
};
|
|
841
|
+
// Prior debt: this session's own sentinel. One naming ANOTHER transcript can only mean the
|
|
842
|
+
// transcript was moved or renamed under the same basename — it is IGNORED and overwritten (or
|
|
843
|
+
// removed below when nothing is armed), never deleted as "foreign": the pre-feature branch that
|
|
844
|
+
// unlinked a sentinel of another transcript deleted the NEIGHBOUR session's live debt (FR-2).
|
|
845
|
+
const own = readSentinel(pendingPath);
|
|
846
|
+
let prior = own.prior;
|
|
847
|
+
const staleOwn = own.present && own.prior === null;
|
|
848
|
+
let adoptLegacyPending = false;
|
|
849
|
+
if (!own.present) {
|
|
850
|
+
// FR-4: the flat sentinel is adopted only when it is THIS transcript's and this session has no
|
|
851
|
+
// per-session copy yet; a stranger's, or a nobody's (no transcript field), is left untouched.
|
|
852
|
+
const legacy = readSentinel(legacyPendingPath);
|
|
853
|
+
if (legacy.prior !== null) { prior = legacy.prior; adoptLegacyPending = true; }
|
|
854
|
+
}
|
|
855
|
+
const hadSentinel = prior !== null;
|
|
770
856
|
|
|
771
857
|
let size = 0;
|
|
772
858
|
try { size = statSync(transcriptPath).size; } catch {
|
|
773
859
|
return prior !== null
|
|
774
|
-
? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset }
|
|
775
|
-
: { status: 'none', scannedBytes: 0, offset };
|
|
860
|
+
? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset, ...ids }
|
|
861
|
+
: { status: 'none', scannedBytes: 0, offset, ...ids };
|
|
776
862
|
}
|
|
777
863
|
if (size < offset) offset = 0; // truncated/rotated transcript
|
|
778
864
|
let jumped = offset === 0 && size > MAX_TAIL_SCAN_BYTES; // bound the first scan of a huge file
|
|
@@ -802,12 +888,12 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
|
|
|
802
888
|
|
|
803
889
|
const next = foldAdmissionDebt(events, prior);
|
|
804
890
|
const newOffset = offset + consumed;
|
|
805
|
-
mkdirSync(
|
|
891
|
+
mkdirSync(dir, { recursive: true });
|
|
806
892
|
let outcome: TailScanOutcome;
|
|
807
893
|
if (next !== null) {
|
|
808
894
|
const sentinel: RetroPendingSentinel = {
|
|
809
895
|
schema: 1,
|
|
810
|
-
sessionId
|
|
896
|
+
sessionId,
|
|
811
897
|
transcript: transcriptPath,
|
|
812
898
|
snippet: next.snippet.replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
813
899
|
ts: nowIso ?? new Date().toISOString(),
|
|
@@ -826,13 +912,45 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
|
|
|
826
912
|
catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
|
827
913
|
outcome = { status: 'cleared', scannedBytes: consumed, offset: newOffset };
|
|
828
914
|
} else {
|
|
915
|
+
// Our own stale sentinel (moved transcript, see above) with nothing armed: removed, so the hook
|
|
916
|
+
// never confronts a debt this transcript no longer carries. Same ENOENT-only tolerance.
|
|
917
|
+
if (staleOwn) {
|
|
918
|
+
try { unlinkSync(pendingPath); }
|
|
919
|
+
catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
|
920
|
+
}
|
|
829
921
|
outcome = { status: 'none', scannedBytes: consumed, offset: newOffset };
|
|
830
922
|
}
|
|
831
923
|
// Commit the debt BEFORE its offset: an interruption must leave these bytes replayable.
|
|
832
924
|
writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
|
|
833
|
-
|
|
925
|
+
// FR-4: the adopted flat files go only AFTER the per-session copies are committed — a failure
|
|
926
|
+
// above leaves them in place for the next scan to adopt again. Best-effort: leftover debris
|
|
927
|
+
// is harmless (a present per-session file blocks re-adoption), a thrown error here would
|
|
928
|
+
// misreport an already-committed scan.
|
|
929
|
+
if (adoptLegacyPending) { try { unlinkSync(legacyPendingPath); } catch { /* debris */ } }
|
|
930
|
+
if (adoptLegacyState) { try { unlinkSync(legacyStatePath); } catch { /* debris */ } }
|
|
931
|
+
sweepStaleSessionDirs(dzDir, dir, nowIso === undefined ? Date.now() : Date.parse(nowIso));
|
|
932
|
+
return { ...outcome, ...ids };
|
|
834
933
|
} catch {
|
|
835
|
-
return { status: 'none', scannedBytes: 0, offset: 0 };
|
|
934
|
+
return { status: 'none', scannedBytes: 0, offset: 0, ...ids };
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* FR-5: remove dead sessions' dirs — `<dzDir>/retro/<x>/` whose `scan-state.json` mtime is older
|
|
940
|
+
* than {@link RETRO_SESSION_STALE_MS}. The scanning session's own dir is excluded; a dir with no
|
|
941
|
+
* readable scan-state cannot be dated and is left alone; every error is swallowed; at most
|
|
942
|
+
* {@link RETRO_SWEEP_MAX_ENTRIES} entries are examined (one readdir + ≤64 stats — NFR-1).
|
|
943
|
+
*/
|
|
944
|
+
function sweepStaleSessionDirs(dzDir: string, ownDir: string, nowMs: number): void {
|
|
945
|
+
let names: string[];
|
|
946
|
+
try { names = readdirSync(join(dzDir, RETRO_SESSION_DIRNAME)).slice(0, RETRO_SWEEP_MAX_ENTRIES); } catch { return; }
|
|
947
|
+
for (const name of names) {
|
|
948
|
+
const d = join(dzDir, RETRO_SESSION_DIRNAME, name);
|
|
949
|
+
if (d === ownDir) continue;
|
|
950
|
+
try {
|
|
951
|
+
const age = nowMs - statSync(join(d, RETRO_SESSION_STATE_BASENAME)).mtimeMs;
|
|
952
|
+
if (age > RETRO_SESSION_STALE_MS) rmSync(d, { recursive: true, force: true });
|
|
953
|
+
} catch { /* undatable or vanished: leave it */ }
|
|
836
954
|
}
|
|
837
955
|
}
|
|
838
956
|
|
|
@@ -851,6 +969,10 @@ export function retroSentinelIsFresh(
|
|
|
851
969
|
ctx: { sessionId?: string; transcriptPath?: string; nowMs: number },
|
|
852
970
|
): boolean {
|
|
853
971
|
if (typeof sentinel.snippet !== 'string' || sentinel.snippet === '') return false;
|
|
972
|
+
// AM-1 (retro-debt-sentinel-per-session): an EMPTY session id carries no identity — a sentinel that
|
|
973
|
+
// names no session never matches anyone, and an empty ctx id is the same as none (the hook side
|
|
974
|
+
// returns '' before any fs call for it; this is the core half of the same rule).
|
|
975
|
+
if (sentinel.sessionId === '') return false;
|
|
854
976
|
if (typeof ctx.sessionId === 'string' && ctx.sessionId !== '') return sentinel.sessionId === ctx.sessionId;
|
|
855
977
|
if (typeof ctx.transcriptPath === 'string' && ctx.transcriptPath !== '') return sentinel.transcript === ctx.transcriptPath;
|
|
856
978
|
const ts = typeof sentinel.ts === 'string' ? Date.parse(sentinel.ts) : NaN;
|