@redue/cli 0.1.0-alpha.2 → 0.1.0-alpha.20

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/README.md +107 -2
  3. package/bin/redue.mjs +130 -49
  4. package/docs/README.md +2 -1
  5. package/docs/user/agents.md +299 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +209 -0
  8. package/docs/user/getting-started.md +23 -2
  9. package/docs/user/json.md +2 -0
  10. package/docs/user/node-script.md +153 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/receipt-selection-notice.md +60 -0
  14. package/docs/user/supported-projects.md +49 -21
  15. package/docs/user/tests-direct.md +132 -0
  16. package/package.json +4 -3
  17. package/src/agent-integration.mjs +388 -0
  18. package/src/agent-policy.md +28 -0
  19. package/src/cached-state.mjs +46 -8
  20. package/src/claude-hook.mjs +449 -0
  21. package/src/cli.mjs +263 -40
  22. package/src/daemon.mjs +433 -94
  23. package/src/decision-profile.mjs +15 -1
  24. package/src/decision-validation.mjs +10 -0
  25. package/src/declared-plan.mjs +29 -6
  26. package/src/direct-run-certificate.mjs +252 -0
  27. package/src/direct-typescript-probe.mjs +24 -0
  28. package/src/direct-typescript.mjs +185 -0
  29. package/src/glob.mjs +8 -0
  30. package/src/history-worker.mjs +21 -13
  31. package/src/index.mjs +72 -19
  32. package/src/installed-inputs.mjs +7 -1
  33. package/src/linux-inotify.py +39 -10
  34. package/src/node-onboarding.mjs +157 -48
  35. package/src/node-script.mjs +362 -0
  36. package/src/npm-integrity.mjs +124 -0
  37. package/src/npm-typecheck-environment.mjs +36 -0
  38. package/src/npmrc.mjs +16 -0
  39. package/src/observation.mjs +34 -12
  40. package/src/path-identity.mjs +8 -0
  41. package/src/plan-worker.mjs +17 -17
  42. package/src/pnpm-inputs.mjs +114 -54
  43. package/src/pnpm-shim-templates.json +180 -0
  44. package/src/pnpm-shims.mjs +54 -0
  45. package/src/pnpm-typecheck-probe.mjs +11 -6
  46. package/src/presentation.mjs +9 -1
  47. package/src/probe-deadline.mjs +37 -0
  48. package/src/probe-result.mjs +7 -3
  49. package/src/project-location.mjs +34 -0
  50. package/src/run-recorder.cjs +91 -0
  51. package/src/state-store.mjs +37 -1
  52. package/src/test-direct.mjs +560 -0
  53. package/src/test-recorder.cjs +406 -0
  54. package/src/typescript-contract-probe.mjs +4 -187
  55. package/src/typescript-input-facts.mjs +92 -0
  56. package/src/typescript-invocation.mjs +92 -0
  57. package/src/typescript-list.mjs +108 -13
  58. package/src/typescript-qualification.mjs +29 -49
  59. package/src/workspace-manifests.mjs +22 -0
  60. package/src/yarn-workspace-typecheck-probe.mjs +2 -1
package/src/daemon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import {inflateSync} from 'node:zlib';
1
+ import {inflateSync,deflateSync} from 'node:zlib';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import net from 'node:net';
@@ -8,16 +8,23 @@ import {Worker} from 'node:worker_threads';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import {subscribe,hasHistory,backend} from './platform-observation.mjs';
10
10
  import {controlEndpoint} from './control-endpoint.mjs';
11
- import {observedRelative,withinPath,realObservedPath} from './path-identity.mjs';
11
+ import {observedRelative,withinPath,realObservedPath,racyWindowMs} from './path-identity.mjs';
12
12
  import micromatch from './glob.mjs';
13
13
  import {sha} from './plan.mjs';
14
- import {InputIndex} from './index.mjs';
14
+ import {InputIndex,dehydrateIndex} from './index.mjs';
15
15
  import {permittedRoots} from './installed-inputs.mjs';
16
16
  import {assertOwnedStatePlacement} from './owned-state.mjs';
17
- import {atomicJson,readReceipts} from './state-store.mjs';
18
- import {history} from './observation.mjs';
19
- import {inputKey} from './decision-validation.mjs';
20
- import {timing} from './decision-profile.mjs';
17
+ import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
18
+ import {history,closeHistory} from './observation.mjs';
19
+ import {inputKey,planGuard,toolchainKey} from './decision-validation.mjs';
20
+ import {queriesHold,runFactReason as runFactChanged} from './typescript-list.mjs';
21
+ import {nextProbeHint,probeDeadline,probeFloorMs} from './probe-deadline.mjs';
22
+ import {statusSummary} from './cached-state.mjs';
23
+ import {directProject,directProbeValue} from './direct-typescript.mjs';
24
+ import {npmCacheDir} from './npm-integrity.mjs';
25
+ import {runFromArgv} from './typescript-invocation.mjs';
26
+ import {buildInfoPath,recordedCertificate} from './direct-run-certificate.mjs';
27
+ import {timing,mark,traceBase,traceContext,traceScope,traceEnvironment,traceId} from './decision-profile.mjs';
21
28
 
22
29
  const configFile=path.resolve(process.argv[2]);
23
30
  const config=JSON.parse(fs.readFileSync(configFile));
@@ -31,8 +38,10 @@ const endpoint=controlEndpoint(state),socket=endpoint.address,
31
38
  fs.mkdirSync(lock); // Existing lock requires operator inspection; never steal it.
32
39
  fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
33
40
  const generation=randomUUID(), startedAt=Date.now();
41
+ traceBase({generation,work:'startup'});
34
42
  let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
35
43
  let receiptStateError=null;
44
+ let receiptStateRevision=null;
36
45
  const external=new Map();
37
46
  for(const check of config.checks)for(const boundary of permittedRoots(root,check.allowedExternalRoots||[]))
38
47
  if(!external.has(boundary))external.set(boundary,{root:boundary,watcher:null,index:null,
@@ -43,6 +52,9 @@ let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, pla
43
52
  let decisionRequests=0;
44
53
  let decisionSequence=0;
45
54
  let inputEventSerial=new Map();
55
+ // The last input-event paths per check, with their serials, so a test run can
56
+ // show that every event during it was one of its own outputs.
57
+ const inputEventPaths=new Map(),eventPathLimit=10000;
46
58
  let diagnosticInputEvents=0;
47
59
  let synchronizing=null;
48
60
  let installedRecheck=null;
@@ -77,9 +89,20 @@ function event(kind,fields={}) {
77
89
  repository:root,...fields})+'\n');
78
90
  }
79
91
  function loadReceipts() {
80
- try {receipts=readReceipts(state).checks;receiptStateError=null;}
81
- catch(e){receipts={};receiptStateError=e.message;event('receipt_state_unavailable',
82
- {classification:e.name||'Error'});}
92
+ try {const snapshot=readReceiptSnapshot(state);receipts=snapshot.value.checks;
93
+ receiptStateRevision=snapshot.revision;receiptStateError=null;}
94
+ catch(e){receiptStateRevision=null;
95
+ // A readable selector can still supply explicitly historical outcomes after
96
+ // restart. The failed snapshot guard remains authoritative for applicability.
97
+ try{receipts=readReceipts(state).checks;}catch{/* retain previous historical rows */}
98
+ const next=`latest receipt unavailable; prior outcome only: ${e.message}`;
99
+ if(receiptStateError!==next)event('receipt_state_unavailable',
100
+ {classification:e.name||'Error'});
101
+ receiptStateError=next;}
102
+ }
103
+ function refreshReceipts() {
104
+ try {if(receiptStateRevision===null||receiptRevision(state)!==receiptStateRevision)loadReceipts();}
105
+ catch {loadReceipts();}
83
106
  }
84
107
  function persistedPlan(bundle) {
85
108
  return {schema:1,provider:bundle.provider,root:bundle.root,discoveredAt:bundle.discoveredAt,
@@ -98,54 +121,150 @@ function persistedPlan(bundle) {
98
121
  return [name,safe];
99
122
  }))};
100
123
  }
101
- function probe(row,allowReuse=false) {
124
+ // Continuity for in-process certificate reuse: every observed change is applied.
125
+ // macOS needs its history cursor; Linux has the synchronize barrier in catchUp.
126
+ // Windows reconciles content at each decision and keeps its own reuse path.
127
+ function continuityBarrier() {
128
+ return process.platform!=='win32'&&(process.platform!=='darwin'||hasHistory&&!historyDisabled)&&
129
+ healthy&&watcher&&!planning&&!recovering&&!planDirty&&!index?.unavailable&&
130
+ !pending.size&&!pendingChecks.size&&!external.size&&verifyRoot();
131
+ }
132
+ // The basis a full direct probe is checked against: plan guard, observation
133
+ // continuity, and the compiler tree and Node identity taken before the probe.
134
+ function certificateBasis(row) {
135
+ const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
136
+ return {guard:planGuard(index.bundle),gaps:metrics.gaps,
137
+ compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
138
+ toolchain:toolchainKey(compiler.tsRoot,argv[0])};
139
+ }
140
+ // Returns null after reusing the last full probe, otherwise why a full probe is needed.
141
+ function reuseCertificate(row,key,allowReuse,forceFull,checked) {
142
+ const started=performance.now(),cache=row.probeCache;
143
+ if(forceFull)return 'forced_full_reference';
144
+ if(!allowReuse||!continuityBarrier())return 'continuity_unavailable';
145
+ if(row.plan.unresolved.length)return 'coverage_unresolved';
146
+ if(!cache?.queries?.length)return 'no_validated_result';
147
+ if(cache.input!==key)return 'inputs_changed';
148
+ if(cache.gaps!==metrics.gaps)return 'observation_gap';
149
+ let reason=null;
150
+ try{const basis=checked.basis=certificateBasis(row);
151
+ if(basis.guard!==cache.guard){planDirty=true;reason='plan_guard_changed';}
152
+ else if(basis.compiler!==cache.compiler||basis.toolchain!==cache.toolchain)reason='toolchain_changed';
153
+ else if(!queriesHold(cache.queries,cache.identities))reason='compiler_queries_changed';}
154
+ catch{reason='validation_unavailable';}
155
+ timing('daemon.probe_validation',started,{outcome:reason?'changed_or_unavailable':'reused',
156
+ reason:reason||'content_and_context_unchanged',queries:cache.queries.length});
157
+ if(reason)return reason;
158
+ row.probeHash=cache.hash;row.probeAt=Date.now();row.probeError=null;
159
+ metrics.probeReuses=(metrics.probeReuses||0)+1;
160
+ return null;
161
+ }
162
+ // The current full-probe deadline for a check; see probe-deadline.mjs. Hints
163
+ // are kept per check name, so a plan rebuild (each Windows decision) keeps them.
164
+ const probeHints=new Map();
165
+ function fullProbeDeadline(row) {
166
+ const run=receipts[row.plan.name]?.invocation;
167
+ return probeDeadline({floor:probeFloorMs(row.plan.qualification),hint:probeHints.get(row.plan.name),
168
+ runMs:run?.status==='exited'?run.durationMs:0});
169
+ }
170
+ function probe(row,allowReuse=false,{launch=true}={}) {
102
171
  const started=performance.now();
103
172
  const supported=new Map([
104
173
  ['typescript-noemit-v1','typescript-contract-probe.mjs'],
174
+ ['npm-typescript-direct-v1','direct-typescript-probe.mjs'],
105
175
  ['yarn-workspace-tsc-v1','yarn-workspace-typecheck-probe.mjs'],
106
176
  ['pnpm-tsc-v1','pnpm-typecheck-probe.mjs']]);
107
177
  const expected=supported.get(row.plan.qualification);
108
- const cacheable=process.platform==='win32'&&row.plan.probes.length===1&&expected&&
178
+ const builtIn=row.plan.probes.length===1&&expected&&
109
179
  row.plan.probes[0][1]===fileURLToPath(new URL(expected,import.meta.url));
180
+ const inProcess=builtIn&&process.platform!=='win32'&&row.plan.qualification==='npm-typescript-direct-v1';
181
+ const cacheable=builtIn&&(process.platform==='win32'||inProcess);
110
182
  const key=inputKey(row);
111
- if(cacheable&&allowReuse&&row.probeCache?.input===key){
183
+ const forceFull=process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_PROBES==='1';
184
+ let fullReason=null;const checked={};
185
+ if(inProcess){
186
+ fullReason=reuseCertificate(row,key,allowReuse,forceFull,checked);
187
+ if(!fullReason)return;
188
+ }
189
+ // Idle revalidation never launches a process. An unconfirmed result expires.
190
+ if(!launch&&row.plan.probes.length)return;
191
+ if(process.platform==='win32'&&cacheable&&allowReuse&&!forceFull&&row.probeCache?.input===key){
112
192
  const argv=row.plan.probes[0];
193
+ mark('daemon.probe_launch',{mode:'certificate',deadlineMs:10000});
113
194
  const out=spawnSync(argv[0],[...argv.slice(1),'--redue-context'],{
114
- cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
115
- if(!out.error&&!out.signal&&out.status===0&&
116
- out.stdout.toString()===row.probeCache.context){
195
+ cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,env:traceEnvironment(),stdio:['ignore','pipe','pipe']});
196
+ mark('daemon.probe_complete',{mode:'certificate',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
197
+ metrics.probeContextProcesses=(metrics.probeContextProcesses||0)+1;
198
+ if(!out.error&&!out.signal&&out.status===0&&out.stdout.toString()===row.probeCache.context){
117
199
  row.probeHash=row.probeCache.hash;row.probeAt=Date.now();row.probeError=null;
118
200
  metrics.probeReuses=(metrics.probeReuses||0)+1;
119
201
  timing('daemon.probe_context_validation',started,{outcome:'reused',reason:'content_and_context_unchanged'});
120
202
  return;
121
203
  }
204
+ fullReason='context_or_compiler_queries_changed';
122
205
  timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
123
206
  }
124
- timing('daemon.probe_reason',started,{reason:!cacheable?'generic_probe':
125
- !row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed'});
207
+ fullReason??=forceFull?'forced_full_reference':!cacheable?'generic_probe':
208
+ !row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed';
209
+ if(row.plan.probes.length){
210
+ timing('daemon.probe_reason',started,{reason:fullReason});
211
+ metrics.fullProbeReasons??={};metrics.fullProbeReasons[fullReason]=(metrics.fullProbeReasons[fullReason]||0)+1;
212
+ metrics.fullProbes=(metrics.fullProbes||0)+1;
213
+ }
214
+ // A failed or incomplete full probe must not leave an older certificate usable.
215
+ let basis=checked.basis||null;
216
+ if(inProcess){
217
+ if(!basis)try{basis=certificateBasis(row);}catch{}
218
+ if(row.probeCache?.guard&&basis&&basis.guard!==row.probeCache.guard)planDirty=true;
219
+ row.probeCache=null;
220
+ }
126
221
  const values=[];let checkpoint=null;
222
+ // The slowest of this check's probes sets the hint for all of them.
223
+ const deadline=fullProbeDeadline(row);let hint=0;
224
+ const learn=()=>{if(hint)probeHints.set(row.plan.name,hint);};
127
225
  for(const argv of row.plan.probes) {
128
- // Supported TypeScript probes perform bounded input/toolchain hashing.
129
- // Native NTFS runs have exceeded the generic 5 s cap under load, despite
130
- // completing normally and with the duplicated work already removed.
131
- const timeout=row.plan.qualification==='pnpm-tsc-v1'||
132
- (process.platform==='win32'&&['typescript-noemit-v1','yarn-workspace-tsc-v1']
133
- .includes(row.plan.qualification))?10000:5000;
134
- const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[])],{cwd:row.plan.cwd,
135
- timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
226
+ const timeout=deadline,launched=performance.now();
227
+ mark('daemon.probe_launch',{mode:'full',reason:fullReason,deadlineMs:timeout});
228
+ // A built-in probe gets its deadline, so its inner compiler calls share it.
229
+ const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[]),
230
+ ...(builtIn?[`--redue-deadline=${timeout}`]:[])],{cwd:row.plan.cwd,
231
+ timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:traceEnvironment()});
232
+ mark('daemon.probe_complete',{mode:'full',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
233
+ const timedOut=out.error?.code==='ETIMEDOUT';
234
+ if(!out.error||timedOut)hint=Math.max(hint,nextProbeHint({durationMs:performance.now()-launched,deadlineMs:timeout,timedOut}));
136
235
  if(out.error||out.signal||out.status!==0) {
236
+ learn();
137
237
  if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
138
238
  row.probeHash=null;row.probeAt=0;
139
- const code=['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
239
+ const code=['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
140
240
  .includes(row.plan.qualification)?
141
241
  /VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
142
242
  const explanations={
243
+ 'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
244
+ 'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
245
+ 'direct-typescript-lockfile-source-unsupported':'package-lock.json installs TypeScript from somewhere other than the registry\'s typescript package (an alias, a file or a git source)',
246
+ 'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
247
+ 'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
248
+ 'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
249
+ 'npm-lockfile-unreadable':'package-lock.json cannot be read',
250
+ 'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
251
+ 'npm-tarball-uncached':`the TypeScript tarball package-lock.json names is not in npm's cache (${npmCacheDir(root)}); run npm ci or npm cache add`,
252
+ 'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
253
+ 'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
254
+ 'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
255
+ 'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
256
+ 'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
257
+ 'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
143
258
  'typecheck-script-changed':'typecheck script or lifecycle changed; rerun init or review it',
144
259
  'execution-environment-unsupported':'Node/npm preload or configuration environment is unsupported',
145
260
  'typescript-executable-changed':'local TypeScript executable changed or is missing',
146
261
  'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
147
262
  'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
148
- 'typescript-incremental-unsupported':'TypeScript incremental or composite compilation needs explicit review',
263
+ 'typescript-incremental-unsupported':'TypeScript composite compilation needs explicit review',
264
+ 'typescript-buildinfo-outside-repository':'TypeScript incremental build-info file is outside the observed repository',
265
+ 'typescript-incremental-unsound':'TypeScript assumeChangesOnlyAffectDirectDependencies makes an incremental result depend on build history',
266
+ 'typescript-buildinfo-name-unsupported':'TypeScript build-info file needs a plain name ending in .tsbuildinfo',
267
+ 'typescript-project-reference-consumed':'TypeScript program uses files of a referenced project, which plain tsc does not build',
149
268
  'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
150
269
  'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
151
270
  'typescript-input-list-unavailable':'TypeScript input listing did not complete',
@@ -154,6 +273,9 @@ function probe(row,allowReuse=false) {
154
273
  'npm-installation-unobservable':'npm installation contains unobservable entries',
155
274
  'npm-configuration-unsupported':'npm script configuration needs review',
156
275
  'typescript-contract-unavailable':'TypeScript applicability probe could not establish inputs',
276
+ 'npm-proxy-version-unsupported':'npm proxy context is reviewed only for npm 10.9.2',
277
+ 'npm-proxy-context-ambiguous':'conflicting npm proxy environment keys require resolution',
278
+ 'npm-proxy-value-unsupported':'npm proxy environment contains an unsupported value',
157
279
  'workspace-execution-environment-unsupported':'Yarn/Node launcher environment override needs review',
158
280
  'yarn-toolchain-unavailable':'pinned Yarn version or executable is unavailable',
159
281
  'yarn-distribution-unobservable':'Corepack Yarn distribution cannot be inspected',
@@ -161,7 +283,7 @@ function probe(row,allowReuse=false) {
161
283
  'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
162
284
  ,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
163
285
  ,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
164
- ,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
286
+ ,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
165
287
  ,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
166
288
  ,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
167
289
  ,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
@@ -175,18 +297,26 @@ function probe(row,allowReuse=false) {
175
297
  try{checkpoint=JSON.parse(out.stdout);
176
298
  checkpoint.queries=checkpoint.queryData?JSON.parse(inflateSync(
177
299
  Buffer.from(checkpoint.queryData,'base64'),{maxOutputLength:8*1024*1024})):null;
300
+ // Optional: without a layout, a run after an edit falls back to a full probe.
301
+ try{checkpoint.layout=checkpoint.layoutData?JSON.parse(inflateSync(
302
+ Buffer.from(checkpoint.layoutData,'base64'),{maxOutputLength:8*1024*1024})):null;}
303
+ catch{checkpoint.layout=null;}
178
304
  if(!/^[a-f0-9]{64}$/.test(checkpoint.output)||!/^[a-f0-9]{64}$/.test(checkpoint.context))
179
305
  throw Error('invalid supported probe checkpoint');}
180
306
  catch{row.probeHash=null;row.probeAt=0;row.probeError='probe checkpoint unavailable';return;}
181
307
  }
182
308
  values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
183
309
  }
310
+ learn();
184
311
  const value=sha(JSON.stringify(values));
185
312
  if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
186
313
  row.revision++;row.lastReason='declared state probe changed or recovered';}
187
314
  row.probeHash=value;row.probeAt=Date.now();row.probeError=null;
188
- if(checkpoint)row.probeCache=checkpoint.queries?{input:key,context:checkpoint.context,
189
- hash:value,queries:checkpoint.queries}:null;
315
+ if(checkpoint)row.probeCache=checkpoint.queries&&(!inProcess||basis)?{input:key,context:checkpoint.context,
316
+ hash:value,queries:checkpoint.queries,...(inProcess?{schema:1,...basis,
317
+ identities:new Map(),
318
+ output:checkpoint.output,queryData:checkpoint.queryData,layout:checkpoint.layout,
319
+ stderrHash:values[0][3]}: {})}:null;
190
320
  timing('daemon.probe',started,{outcome:'ok',commands:values.length,
191
321
  reusable:row.probeCache?1:0,queries:row.probeCache?.queries.length||0});
192
322
  }
@@ -221,7 +351,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
221
351
  const item={name,result,freshness:'UNVERIFIED',reason:'no receipt',changed_inputs:[],
222
352
  plan_id:row.plan.id,observed_at:lastObservation,
223
353
  invocation:r?.invocation||null,target_provenance:r?.target||null,
224
- coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false};
354
+ ...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
355
+ ...(r?.recipe?{recipe:r.recipe}:{}),
356
+ coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
357
+ ...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
225
358
  if(receiptStateError){item.reason=receiptStateError;return item;}
226
359
  if(!healthy||planning||recovering||index.unavailable){item.reason=
227
360
  (planning||recovering)?'input observation reconciliation pending':
@@ -232,7 +365,8 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
232
365
  if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
233
366
  if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
234
367
  r.fingerprint===combinedSummary(name).fingerprint;
235
- if(r&&r.fingerprintVersion!==1){item.reason='receipt uses earlier input-resolution format';return item;}
368
+ if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
369
+ item.reason='receipt uses earlier input-resolution format';return item;}
236
370
  if(row.plan.unresolved.length){
237
371
  const checkpoint=item.declared_inputs_match===false?
238
372
  r.planId!==row.plan.id?'; observed input plan changed':
@@ -241,7 +375,13 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
241
375
  item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
242
376
  return item;
243
377
  }
244
- if(row.probeError||!row.probeAt||Date.now()-row.probeAt>10000){item.reason=row.probeError||'state probe observation expired';return item;}
378
+ if(row.probeError){item.reason=row.probeError;return item;}
379
+ // The plan leaves out the build-info file it saw at start; the probe sees the current one.
380
+ if(row.probeCache?.layout&&(row.probeCache.layout.buildInfo||null)!==(row.plan.buildInfo||null)){
381
+ item.reason='the incremental build-info setting changed after the observer started; restart the observer';return item;}
382
+ // An expired probe or missing execution context cannot confirm CURRENT, but
383
+ // observed input content that already differs from the receipt is STALE.
384
+ let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
245
385
  if(row.plan.synchronizedInstalledRead&&!synchronizedInstalled){
246
386
  item.reason='installed inputs require a synchronized content read';return item;}
247
387
  if(process.platform==='win32'&&!synchronizedFilesystem){
@@ -254,15 +394,15 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
254
394
  item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
255
395
  if(r.target.status==='failed'&&result!=='FAIL'){
256
396
  item.reason='target failure conflicts with outer invocation outcome';return item;}
257
- if(!r.stable){item.reason='inputs changed during execution or observation gap';return item;}
397
+ if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
398
+ item.reason=`inputs changed during execution or observation gap${issues}`;return item;}
258
399
  const names=[...(row.plan.environment.variables||[]),
259
400
  ...(row.plan.environment.prefixes||[]).map(prefix=>
260
401
  `__vstate_env_prefix_${prefix.toLowerCase()}__`),
261
402
  ...(row.plan.environment.pathExecutables||[]).map(name=>
262
403
  `__vstate_path_executable_${name}__`),
263
404
  ...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
264
- if(names.length&&!contextHashes?.[name]){
265
- item.reason='execution environment context not supplied';return item;}
405
+ if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
266
406
  item.freshness='STALE';
267
407
  if(r.planId!==row.plan.id){
268
408
  const before=new Map((r.resolutionLinks||[]).map(link=>[link.path,link.targetHash]));
@@ -270,8 +410,16 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
270
410
  item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
271
411
  if(changed)item.changed_inputs=[changed.path];
272
412
  }
273
- else if(names.some(key=>contextHashes[name]?.[key]!==r.environmentHashes?.[key]))
413
+ else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
274
414
  item.reason='declared execution environment changed';
415
+ // Every variable a test run read directly must have the same value. The
416
+ // caller compares them and names the list it used; another list confirms nothing.
417
+ else if(r.environmentReads&&contextHashes?.[name]&&
418
+ (contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))||contextHashes[name].__redue_env_changed__)){
419
+ if(contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))){
420
+ item.freshness='UNVERIFIED';item.reason='environment variables the last run read were not compared';}
421
+ else item.reason=`environment variable ${contextHashes[name].__redue_env_changed__} differs from the last run`;
422
+ }
275
423
  else if(r.fingerprint!==combinedSummary(name).fingerprint) {
276
424
  const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
277
425
  if(row.staleReasonKey!==key){
@@ -285,27 +433,98 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
285
433
  if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
286
434
  }
287
435
  row.staleReason=changed||
288
- (['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
436
+ (['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
289
437
  .includes(row.plan.qualification)?
290
438
  'TypeScript input set, generated file, or toolchain context changed':
291
439
  'declared input content or membership changed');
292
440
  row.staleReasonKey=key;
293
441
  }
294
442
  item.reason=row.staleReason;item.changed_inputs=row.staleInputs||[];
443
+ if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
444
+ item.reason=unconfirmed;item.changed_inputs=[];}
445
+ } else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
446
+ // Lookups the run made outside the certificate are asked again for every
447
+ // answer, cached or synchronized, before it can say CURRENT.
448
+ // A direct run recorded before run facts may have made lookups no probe
449
+ // asks; only Windows, which never records direct runs, keeps reusing them.
450
+ } else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
451
+ item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
452
+ } else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
453
+ // A recorded test run that used something REDUE does not observe, or
454
+ // whose result differed from an earlier run on the same inputs.
455
+ } else if(r.guardIssues?.length){item.freshness='UNVERIFIED';
456
+ item.reason=`the run used inputs REDUE does not observe: ${r.guardIssues.slice(0,3).join('; ')}`+
457
+ (r.guardIssues.length>3?`; and ${r.guardIssues.length-3} more`:'');
458
+ } else if(r.nondeterministic){item.freshness='UNVERIFIED';item.reason='runs on these exact inputs had different results';
295
459
  } else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
296
460
  item.reuse_eligible=result==='PASS';}
297
461
  return item;
298
462
  });
299
463
  }
464
+ // Null when every run fact in a receipt answers as it did during the run.
465
+ function runFactReason(facts) {
466
+ const started=performance.now(),reason=runFactChanged(facts);
467
+ timing('daemon.run_facts',started,{facts:facts.length,changed:reason?1:0});
468
+ return reason;
469
+ }
470
+ // The indexed inputs already differ from the latest receipt, so no probe result
471
+ // can make this check CURRENT. A sync answers STALE without a full probe. A run
472
+ // probes, or builds the certificate from its recording (adopt), and a revert
473
+ // makes the inputs match and probes again.
474
+ function knownStale(name,row) {
475
+ const r=receipts[name];
476
+ if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
477
+ row.probeCache?.input===inputKey(row))return false;
478
+ const current=combinedFiles(name),files=Object.keys(current);
479
+ return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
480
+ }
481
+ // A run can rebuild a certificate that no longer applies only because indexed
482
+ // inputs changed: same plan guard, compiler, toolchain and observation.
483
+ function adoptable(row) {
484
+ const cache=row.probeCache;
485
+ if(process.platform==='win32'||row.plan.qualification!=='npm-typescript-direct-v1'||
486
+ row.plan.unresolved.length||row.probeError||cache?.schema!==1||!cache.layout||!cache.queries?.length||
487
+ cache.input===inputKey(row)||cache.gaps!==metrics.gaps||!continuityBarrier())return false;
488
+ try{const basis=certificateBasis(row);
489
+ return basis.guard===cache.guard&&basis.compiler===cache.compiler&&basis.toolchain===cache.toolchain;}
490
+ catch{return false;}
491
+ }
492
+ // The end checkpoint of a run that started without a certificate. Its recorded
493
+ // answers become the candidate certificate when nothing observed changed during
494
+ // the run. The probe that follows validates it, or runs in full as before.
495
+ function adopt(name,row,token) {
496
+ const started=performance.now(),pending=row.adoption,file=path.join(state,`run-record-${token}.json`);
497
+ row.adoption=null;
498
+ let text=null;
499
+ try{text=fs.readFileSync(file,'utf8');}catch{}
500
+ const done=issue=>{timing('daemon.recorded_certificate',started,{outcome:issue?'rejected':'accepted',
501
+ ...(issue?{reason:issue}:{})});return issue;};
502
+ if(!pending||pending.token!==token||text===null)return done('recording unavailable');
503
+ if(inputEventSerial.get(name)!==pending.serial||metrics.gaps!==pending.gaps||inputKey(row)!==pending.input||
504
+ row.probeCache!==pending.cache||row.probeHash!==null)
505
+ return done('inputs or certificate changed during the run');
506
+ const cache=pending.cache,argv=row.plan.probes[0];
507
+ let result;
508
+ try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
509
+ catch{return done('recording unreadable');}
510
+ if(result.issue)return done(result.issue);
511
+ const identities=new Map(cache.identities);
512
+ for(const changed of result.changed)identities.delete(changed);
513
+ row.probeCache={...cache,hash:directProbeValue(argv,result.output,cache.stderrHash),
514
+ queries:result.queries,identities,output:result.output,layout:result.layout,input:pending.input,recorded:token,
515
+ queryData:deflateSync(JSON.stringify(result.queries)).toString('base64')};
516
+ metrics.recordedCertificates=(metrics.recordedCertificates||0)+1;
517
+ return done(null);
518
+ }
300
519
  function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
301
- const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
302
- failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
303
- observedFailed=checks.filter(r=>r.result==='FAIL').length,
304
- stale=checks.filter(r=>r.freshness==='STALE').length,
305
- unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
306
- const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
307
- const now=Date.now();return {schema:1,state:stateName,current,failed,observed_failed:observedFailed,
308
- stale,unverified,checks,
520
+ // Reload notifications accelerate visibility but are not required for it: a
521
+ // wrapper may persist its outcome even when its control connection is denied.
522
+ refreshReceipts();
523
+ const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
524
+ // A decision may run every check's probes in turn: callers wait for all of them.
525
+ const probing=[...(index?.checks.values()||[])].filter(row=>row.plan.probes.length);
526
+ const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
527
+ ...(probing.length?{probe_deadline_ms:probing.reduce((sum,row)=>sum+fullProbeDeadline(row)*row.plan.probes.length,0)}:{}),
309
528
  observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
310
529
  generation,observed_at:lastObservation,
311
530
  events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
@@ -340,16 +559,10 @@ function hydrate(bundle,saved) {
340
559
  }
341
560
  next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
342
561
  next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
562
+ next.racy=new Map(saved.racy||[]);
343
563
  return next;
344
564
  }
345
- function dehydrate(value) {
346
- return {files:[...value.files],
347
- aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
348
- rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
349
- sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
350
- eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
351
- rehashedBytes:value.rehashedBytes,profile:value.profile};
352
- }
565
+ const dehydrate=dehydrateIndex;
353
566
  function retainIndex() {
354
567
  if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
355
568
  !fs.existsSync(snapshotPath))return;
@@ -369,7 +582,7 @@ function buildInWorker(forceCold=false,reuse=null) {
369
582
  return new Promise((resolve,reject)=>{
370
583
  const workerStarted=performance.now();
371
584
  const file=path.join(path.dirname(fileURLToPath(import.meta.url)),'plan-worker.mjs');
372
- const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,
585
+ const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,diagnostic:traceContext(),
373
586
  reconcileFaultFile:process.env.VSTATE_TEST_RECONCILE_FAULT_FILE}});
374
587
  planningWorker=worker;let settled=false;
375
588
  const deadline=setTimeout(()=>{
@@ -394,7 +607,7 @@ function buildInWorker(forceCold=false,reuse=null) {
394
607
  reject(Error(`input-plan worker exited ${code}`));}});
395
608
  });
396
609
  }
397
- async function refreshPlan({forceCold=false,reuse=false}={}) {
610
+ async function refreshPlan({forceCold=false,reuse=false,skip=null}={}) {
398
611
  if(planning||stopping)return;
399
612
  const totalStarted=performance.now();
400
613
  const previousProbes=new Map([...(index?.checks||[])].map(([name,row])=>[name,row.probeCache]));
@@ -477,7 +690,8 @@ async function refreshPlan({forceCold=false,reuse=false}={}) {
477
690
  atomic(path.join(state,'plans-v1.json'),persistedPlan(bundle));
478
691
  metrics.lastPlanPersistenceMs=performance.now()-persistenceStarted;
479
692
  const probeStarted=performance.now();
480
- for(const row of index.checks.values())probe(row,built.planReused);
693
+ if(skip)refreshReceipts();
694
+ for(const [name,row] of index.checks)if(!skip?.(name,row))probe(row,built.planReused);
481
695
  timing('daemon.plan_probes',probeStarted,{checks:index.checks.size});
482
696
  lastObservation=Date.now();
483
697
  event(built.planReused?'plan_validated':'plan_rebuilt',{checks:Object.keys(bundle.plans),workspace_count:bundle.workspaceCount});
@@ -573,14 +787,25 @@ function pnpmTopologyChanged(type,rel) {
573
787
  return stat.isDirectory()||stat.isSymbolicLink();}
574
788
  catch(e){return e.code==='ENOENT';}
575
789
  }
576
- function markInputEvent(name,rel,type) {
790
+ function markInputEvent(name,rel,type,replay) {
577
791
  pendingChecks.add(name);
578
- inputEventSerial.set(name,(inputEventSerial.get(name)||0)+1);
792
+ // macOS delivers each edit twice: live, and again from history replay. The
793
+ // live copy can arrive late, inside a later run. With history, count only the
794
+ // replay copy: the history cursor places it in the right interval.
795
+ if(!hasHistory||historyDisabled||replay){
796
+ const serial=(inputEventSerial.get(name)||0)+1;
797
+ inputEventSerial.set(name,serial);
798
+ // Kept only for recorded test and script checks; trimmed in batches.
799
+ if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)){
800
+ const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
801
+ if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
802
+ }
803
+ }
579
804
  // Disposable public CI only. Never enabled for normal/local repositories.
580
805
  if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
581
- event('public_input_notification',{path:rel,type:type||'unknown',planning});
806
+ event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
582
807
  }
583
- function notification(type,filename) {
808
+ function notification(type,filename,replay=false) {
584
809
  metrics.notifications++;
585
810
  metrics.lastNotificationAt=Date.now();
586
811
  if(!filename){observationGap('unnamed_event');return;}
@@ -588,7 +813,7 @@ function notification(type,filename) {
588
813
  if(rel==='.git'||rel.startsWith('.git/'))return;
589
814
  if(planning) {
590
815
  if(index)for(const name of index.candidates(rel))
591
- if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
816
+ if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
592
817
  pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
593
818
  if(planTrigger(rel)||candidateDescendants.has(rel)||pnpmTopologyChanged(type,rel))
594
819
  planDirty=true;
@@ -612,9 +837,9 @@ function notification(type,filename) {
612
837
  pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
613
838
  if(index) {
614
839
  for(const name of index.candidates(rel))
615
- if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
840
+ if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
616
841
  for(const [prefix,names] of index.prefixes)
617
- if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
842
+ if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type,replay);
618
843
  }
619
844
  if(pending.size>50000){healthy=false;reason='observation backlog overflow';metrics.gaps++;
620
845
  event('observation_gap',{classification:'backlog_overflow'});publish();
@@ -623,23 +848,23 @@ function notification(type,filename) {
623
848
  if(planDirty){healthy=false;reason='input plan possibly changed';}
624
849
  if(planDirty||pendingChecks.size)publish();
625
850
  }
626
- function externalNotification(entry,type,filename) {
851
+ function externalNotification(entry,type,filename,replay=false) {
627
852
  metrics.notifications++;
628
853
  metrics.lastNotificationAt=Date.now();
629
854
  if(!filename){externalGap(entry,'unnamed_event');return;}
630
855
  const rel=filename.toString().split(path.sep).join('/');
631
856
  const absolute=path.join(entry.root,rel);
632
857
  if(planning&&entry.index)for(const name of entry.index.candidates(rel))
633
- if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
858
+ if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
634
859
  if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
635
860
  if(candidateTriggerChanged(absolute))planDirty=true;
636
861
  entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
637
862
  pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
638
863
  if(entry.index) {
639
864
  for(const name of entry.index.candidates(rel))
640
- if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
865
+ if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
641
866
  for(const [prefix,names] of entry.index.prefixes)
642
- if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
867
+ if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
643
868
  }
644
869
  if(planDirty){healthy=false;reason='input plan possibly changed';}
645
870
  publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
@@ -657,7 +882,8 @@ function observationGap(classification,error) {
657
882
  // Once continuity is uncertain, a cursor is no longer authority. Do not
658
883
  // retry native history in this observer generation after reconciliation.
659
884
  historyDisabled=true;
660
- healthy=false;reason=`observation unavailable: ${classification}; deterministic reconciliation pending`;
885
+ const cause=error?.message?` (${error.message})`:'';
886
+ healthy=false;reason=`observation unavailable: ${classification}${cause}; deterministic reconciliation pending`;
661
887
  metrics.gaps++;
662
888
  event('observation_gap',{classification,error_code:error?.code||null});publish();
663
889
  // One bounded attempt per gap; a later independent gap may try again.
@@ -743,14 +969,14 @@ async function catchUp() {
743
969
  lastObservation=Date.now();return;
744
970
  }
745
971
  if(historyDisabled)return;
746
- if(synchronizing)return synchronizing;
972
+ if(synchronizing){mark('daemon.catch_up_join');return synchronizing;}
747
973
  synchronizing=(async()=>{
748
974
  if(!verifyRoot())throw Error('checkout identity changed');
749
975
  if(!watcher)throw Error('subscription unavailable');
750
976
  if(!fs.existsSync(snapshotPath))throw Error('historical cursor missing');
751
977
  {
752
978
  const changes=await queryHistory(root,snapshotPath,'root replay');
753
- for(const change of changes)notification(change.type,path.relative(root,change.path));
979
+ for(const change of changes)notification(change.type,path.relative(root,change.path),true);
754
980
  if(!planning&&(flushTimer||pending.size||planDirty))flush();
755
981
  for(const entry of external.values())if(entry.index) {
756
982
  try{
@@ -760,7 +986,7 @@ async function catchUp() {
760
986
  if(!entry.watcher||!fs.existsSync(entry.snapshot))throw Error('external cursor unavailable');
761
987
  const changes=await queryHistory(entry.root,entry.snapshot,'external replay');
762
988
  for(const change of changes)externalNotification(entry,change.type,
763
- path.relative(entry.root,change.path));
989
+ path.relative(entry.root,change.path),true);
764
990
  }catch(e){if(e.code==='ETIMEDOUT')throw e;
765
991
  externalGap(entry,'historical_replay_failed',e);}
766
992
  }
@@ -774,7 +1000,8 @@ async function catchUp() {
774
1000
  function respond(client,value) {
775
1001
  if(client.destroyed){event('control_reply_unavailable',{classification:'client_destroyed'});return;}
776
1002
  try{const payload=JSON.stringify(value)+'\n';
777
- client.end(payload);client.vstateReplied=true;
1003
+ mark('server.response_constructed',{bytes:payload.length});
1004
+ client.end(payload,()=>mark('server.response_delivered'));client.vstateReplied=true;
778
1005
  if(process.env.VSTATE_DEBUG_CONTROL==='1')event('control_reply_sent',{bytes:payload.length});}
779
1006
  catch(e){event('control_reply_unavailable',{classification:e.name||'serialization_error'});
780
1007
  if(!client.destroyed)client.end('{"error":"control response unavailable"}\n');}
@@ -792,10 +1019,11 @@ async function request(message) {
792
1019
  if(message.action==='sync'||message.action==='snapshot'||message.action==='prelaunch') {
793
1020
  const totalStarted=performance.now();
794
1021
  const decision=++decisionSequence;
1022
+ mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
795
1023
  timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
796
1024
  plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
797
- probe:'reuse_only_with_identical_content_and_context',installed:'current_content_required',
798
- phase:metrics.planPhase||'initializing',pending:pending.size,
1025
+ probe:'reuse_only_with_identical_content_and_context',installed:'current_identity_required',
1026
+ planPhase:metrics.planPhase||'initializing',pending:pending.size,
799
1027
  notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
800
1028
  if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
801
1029
  process.env.VSTATE_TEST_WINDOWS_GAP_FILE&&
@@ -810,9 +1038,17 @@ async function request(message) {
810
1038
  // has been disabled: a recovered subscription may still have queued events.
811
1039
  const needsContentReconciliation=process.platform==='win32'||(hasHistory&&historyDisabled);
812
1040
  if(needsContentReconciliation&&message.action!=='prelaunch'){
813
- const started=performance.now();await refreshPlan({forceCold:true,reuse:true});
1041
+ // Windows probes inside the reconciliation, so a known-STALE sync skips it there.
1042
+ const started=performance.now();
1043
+ await refreshPlan({forceCold:true,reuse:true,skip:message.action==='sync'?knownStale:null});
814
1044
  timing('daemon.request_content_validation',started,{healthy:healthy?1:0});}
815
- if(healthy)try{const started=performance.now();await catchUp();
1045
+ // Without synchronized installed reads, the continuity barrier runs once,
1046
+ // after probing: in-process validation reads the live filesystem, and the
1047
+ // barrier then applies every change before the response is computed.
1048
+ const deferBarrier=process.platform!=='win32'&&!needsContentReconciliation&&
1049
+ message.action!=='prelaunch'&&index&&healthy&&!planning&&![...index.checks].some(([name,row])=>
1050
+ row.plan.synchronizedInstalledRead&&(!message.name||message.name===name));
1051
+ if(healthy&&!deferBarrier)try{const started=performance.now();await catchUp();
816
1052
  timing('daemon.request_catch_up',started);}catch(e){observationGap('historical_query',e);}
817
1053
  if(flushTimer||pending.size||planDirty||[...external.values()].some(e=>e.pending.size))flush();
818
1054
  let synchronizedInstalled=false;
@@ -847,10 +1083,66 @@ async function request(message) {
847
1083
  }catch(e){installedRecheck=null;observationGap('installed_recheck',e);}
848
1084
  }
849
1085
  const probeStarted=performance.now();
850
- if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch')
851
- for(const row of index.checks.values())probe(row);
1086
+ // A run's start checkpoint skips the full probe when its recording can
1087
+ // replace the certificate; the end checkpoint offers that recording.
1088
+ let adoptIntent=false,recorded;const adopting=new Set();
1089
+ if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch'){
1090
+ const continuous=continuityBarrier(),probed=index,gaps=metrics.gaps,decided=Date.now();
1091
+ const selected=[...index.checks].filter(([name])=>!message.name||message.name===name);
1092
+ if(message.action==='snapshot'&&typeof message.adopt==='string'&&/^[0-9a-f-]{36}$/.test(message.adopt)){
1093
+ const row=index.checks.get(message.name);
1094
+ recorded=row?adopt(message.name,row,message.adopt):'recording unavailable';
1095
+ }
1096
+ adoptIntent=message.action==='snapshot'&&message.runCertificate===1&&!message.adopt&&continuous;
1097
+ const keys=new Map(selected.map(([name,row])=>[name,inputKey(row)]));
1098
+ const skip=message.action==='sync'?knownStale:()=>false;
1099
+ if(message.action==='sync')refreshReceipts();
1100
+ // With a deferred barrier, validate in-process first and launch any full
1101
+ // probe only after the barrier, so a queued edit costs one probe, not two.
1102
+ for(const [name,row] of selected)if(!skip(name,row))probe(row,continuous,{launch:!deferBarrier&&!adoptIntent});
1103
+ if(continuous&&process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_MAC_PROBE_SIGNAL){
1104
+ fs.writeFileSync(process.env.VSTATE_TEST_MAC_PROBE_SIGNAL,'validated');
1105
+ await new Promise(resolve=>setTimeout(resolve,200));
1106
+ }
1107
+ // Validation and probes block JS event delivery. Finish with a continuity
1108
+ // barrier so queued edits/gaps cannot be published using the pre-probe index.
1109
+ if(continuous||deferBarrier)try{const started=performance.now();
1110
+ await catchUp();if(planDirty||pending.size||flushTimer)flush();
1111
+ timing('daemon.request_catch_up',started,{after:'probes'});
1112
+ // Rows not yet confirmed, or whose inputs moved meanwhile, are probed
1113
+ // from the barrier, as an undeferred decision would have done.
1114
+ // Decided once, after the barrier. The start probe value is already out
1115
+ // of date; clearing it means whichever probe comes next sets the value
1116
+ // for this unchanged interval without counting as a change during the run.
1117
+ if(adoptIntent&&index===probed&&healthy&&!planning)for(const [name,row] of selected)
1118
+ if(row.probeAt<decided&&adoptable(row)){
1119
+ adopting.add(name);row.probeHash=null;
1120
+ row.adoption={token:randomUUID(),serial:inputEventSerial.get(name)||0,gaps:metrics.gaps,
1121
+ input:inputKey(row),cache:row.probeCache};
1122
+ }
1123
+ const rest=index===probed&&healthy&&!planning?selected.filter(([name,row])=>!skip(name,row)&&
1124
+ !adopting.has(name)&&(row.probeAt<decided||metrics.gaps!==gaps||inputKey(row)!==keys.get(name))):[];
1125
+ const launched=metrics.fullProbes||0;
1126
+ for(const [,row] of rest)probe(row,continuityBarrier());
1127
+ if(continuous&&(metrics.fullProbes||0)>launched){await catchUp();if(planDirty||pending.size||flushTimer)flush();}
1128
+ }catch(e){observationGap('post_probe_history',e);}
1129
+ }
852
1130
  timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
853
1131
  lastObservation=Date.now();publish();
1132
+ // A run checkpoint never holds an installed identity from the racy window:
1133
+ // a same-size write in the same timestamp tick would keep it. Wait the
1134
+ // window out (at most 2 s), record those files again, and mark the
1135
+ // checkpoint unusable if one is still inside it.
1136
+ let racy=false;
1137
+ if(message.action==='snapshot'||message.action==='prelaunch'){
1138
+ const names=message.name?[message.name]:null;
1139
+ const indexes=()=>[index,...[...external.values()].map(e=>e.index)].filter(Boolean);
1140
+ const wait=Math.min(racyWindowMs,Math.max(0,...indexes().map(i=>i.racyUntil(names)))-Date.now());
1141
+ if(wait>0){metrics.racyWaits=(metrics.racyWaits||0)+1;await new Promise(resolve=>setTimeout(resolve,wait+5));}
1142
+ for(const i of indexes())i.settleRacy();
1143
+ racy=indexes().some(i=>i.racyUntil(names)>Date.now());
1144
+ if(racy)metrics.racyCheckpoints=(metrics.racyCheckpoints||0)+1;
1145
+ }
854
1146
  const applicabilityStarted=performance.now();
855
1147
  const data=status(message.contextHashes,synchronizedInstalled,
856
1148
  process.platform==='win32'&&healthy&&!planning&&!planDirty);
@@ -860,8 +1152,43 @@ async function request(message) {
860
1152
  data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
861
1153
  planGeneration:row.plan.id,
862
1154
  inputEventSerial:inputEventSerial.get(name)||0,
863
- probeHash:row.probeHash,
864
- observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
1155
+ // Complete only when the oldest kept event follows the given serial.
1156
+ ...(Number.isSafeInteger(message.sinceSerial)?(()=>{const list=inputEventPaths.get(name)||[],
1157
+ since=message.sinceSerial,later=list.filter(([serial])=>serial>since);
1158
+ return {inputEventPaths:later.map(([,rel])=>rel),
1159
+ inputEventPathsComplete:(inputEventSerial.get(name)||0)-since===later.length};})():{}),
1160
+ probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
1161
+ observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
1162
+ // Export only a currently guarded, check-specific discovery certificate.
1163
+ // The caller compares it with the answers its own compile received. Schema 2
1164
+ // certifies the realpath of every directory readDirectory visits. The
1165
+ // snapshot stays the start of the observed interval, including validation.
1166
+ if(message.runCertificate===1&&continuityBarrier()){
1167
+ const row=index?.checks.get(message.name),cache=row?.probeCache;
1168
+ // Also after an accepted recording: the caller compares its record with
1169
+ // this certificate and finds its run facts with it.
1170
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&
1171
+ !row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
1172
+ cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
1173
+ cache.output&&cache.queryData&&data.snapshots[message.name])
1174
+ data.snapshots[message.name].discoveryCertificate={schema:2,planId:row.plan.id,
1175
+ cwd:row.plan.cwd,probeDefinition:sha(JSON.stringify(row.plan.probes)),
1176
+ context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
1177
+ queryData:cache.queryData};
1178
+ }
1179
+ // For a run checked by full probes: the last probe's questions, only to
1180
+ // find the run's lookups outside them. Never evidence for reuse.
1181
+ if(message.runFacts===1&&data.snapshots[message.name]){
1182
+ const row=index?.checks.get(message.name),cache=row?.probeCache;
1183
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&cache?.queryData&&
1184
+ typeof cache.context==='string'&&cache.input===inputKey(row)&&cache.hash===row.probeHash&&
1185
+ cache.guard===planGuard(index.bundle))
1186
+ data.snapshots[message.name].probeQuestions={context:cache.context,queryData:cache.queryData};
1187
+ }
1188
+ const row=index?.checks.get(message.name);
1189
+ if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
1190
+ data.snapshots[message.name].recordedCertificate=row.adoption.token;
1191
+ if(typeof recorded==='string'&&data.snapshots[message.name])data.snapshots[message.name].recordingIssue=recorded;
865
1192
  }
866
1193
  timing('daemon.request_applicability',applicabilityStarted,{checks:data.checks.length});
867
1194
  timing('daemon.request_total',totalStarted,{action:message.action,
@@ -885,7 +1212,7 @@ async function shutdown() {
885
1212
  healthy=true;retainIndex();healthy=false;}
886
1213
  } catch(e){event('index_retention_unavailable',{classification:e.name||'Error'});}
887
1214
  }
888
- planningWorker?.terminate().catch(()=>{});
1215
+ closeHistory();planningWorker?.terminate().catch(()=>{});
889
1216
  clearInterval(heartbeat);clearInterval(probeTimer);clearTimeout(recoveryTimer);
890
1217
  watcher?.unsubscribe().catch(()=>{});server.close();
891
1218
  for(const entry of external.values()){
@@ -903,15 +1230,19 @@ const server=net.createServer(client=>{
903
1230
  event('control_reply_unavailable',{classification:handled?'closed_without_reply':
904
1231
  'closed_before_request',request_bytes:text.length});});
905
1232
  client.on('data',d=>{
906
- if(handled)return;text+=d;if(text.length>4096){event('control_request_rejected',
1233
+ // A sync request carries each check's context digests, about 1.5 KB per
1234
+ // check; 1 MiB leaves room for hundreds of checks.
1235
+ if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
907
1236
  {classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
908
1237
  if(!text.includes('\n'))return;handled=true;
909
1238
  try{const message=JSON.parse(text);
910
1239
  const decision=message.action==='sync'||message.action==='snapshot';
911
1240
  if(decision)decisionRequests++;
912
- Promise.resolve(request(message)).then(v=>respond(client,v))
913
- .catch(e=>respond(client,{error:e.message}))
914
- .finally(()=>{if(decision)decisionRequests--;});}
1241
+ traceScope({request:message.diagnostic?.request||traceId(),work:'request'},()=>{
1242
+ mark('server.request_received',{activeDecisions:decisionRequests});
1243
+ Promise.resolve(request(message)).then(v=>respond(client,v))
1244
+ .catch(e=>respond(client,{error:e.message}))
1245
+ .finally(()=>{mark('server.request_complete');if(decision)decisionRequests--;});});}
915
1246
  catch(e){respond(client,{error:e.message});}
916
1247
  });
917
1248
  });
@@ -971,15 +1302,23 @@ const heartbeat=setInterval(()=>{
971
1302
  }
972
1303
  publish();
973
1304
  },1000);
974
- const probeTimer=setInterval(async()=>{
1305
+ // Idle revalidation keeps probe results fresh for cached status without
1306
+ // launching a process: certificates are replayed in-process, and one that no
1307
+ // longer holds expires so the next decision runs the full probe. macOS history
1308
+ // replay would fork, so cached status there relies on live notifications.
1309
+ const probeTimer=setInterval(()=>traceScope({request:traceId(),work:'periodic'},async()=>{
1310
+ mark('server.periodic_start',{pending:pending.size,planDirty,planning,decisionRequests});
975
1311
  // Windows cached reads are conservatively UNVERIFIED until a full decision
976
- // reconciliation. Periodic synchronous probes cannot strengthen that cache,
977
- // but can block control requests behind several seconds of child work.
1312
+ // reconciliation, so idle revalidation cannot strengthen them.
978
1313
  if(process.platform==='win32')return;
979
- if(healthy&&index&&!planning&&!decisionRequests){
980
- try{await catchUp();for(const row of index.checks.values())probe(row);publish();}
981
- catch(e){observationGap('periodic_query',e);}
982
- }},5000);
1314
+ if(healthy&&index&&!planning&&!recovering&&!decisionRequests){
1315
+ try{if(!hasHistory)await catchUp();}catch(e){observationGap('periodic_query',e);}
1316
+ if(healthy&&index&&!planning&&!recovering)
1317
+ for(const row of index.checks.values())probe(row,true,{launch:false});
1318
+ if(planDirty)flush();else publish();
1319
+ }
1320
+ mark('server.periodic_complete');
1321
+ }),5000);
983
1322
  process.on('uncaughtExceptionMonitor',error=>{
984
1323
  try{event('observer_uncaught_exception',{pid:process.pid,code:error.code||null,
985
1324
  message:error.message,stack:error.stack});}catch{}