@redue/cli 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (67) hide show
  1. package/CHANGELOG.md +831 -0
  2. package/README.md +157 -99
  3. package/bin/redue.mjs +322 -66
  4. package/docs/README.md +3 -1
  5. package/docs/user/agents.md +333 -15
  6. package/docs/user/configuration.md +28 -2
  7. package/docs/user/direct-typescript.md +219 -0
  8. package/docs/user/getting-started.md +50 -7
  9. package/docs/user/json.md +4 -2
  10. package/docs/user/node-script.md +177 -0
  11. package/docs/user/npm-launcher-notice.md +42 -0
  12. package/docs/user/pilot.md +146 -0
  13. package/docs/user/sandbox.md +326 -0
  14. package/docs/user/shell.md +3 -3
  15. package/docs/user/supported-projects.md +69 -23
  16. package/docs/user/tests-direct.md +143 -0
  17. package/package.json +4 -3
  18. package/src/agent-integration.mjs +467 -0
  19. package/src/agent-policy.md +28 -0
  20. package/src/cached-state.mjs +24 -1
  21. package/src/claude-hook.mjs +616 -0
  22. package/src/cli.mjs +449 -54
  23. package/src/daemon.mjs +442 -93
  24. package/src/decision-profile.mjs +15 -1
  25. package/src/decision-validation.mjs +12 -1
  26. package/src/declared-plan.mjs +38 -6
  27. package/src/direct-run-certificate.mjs +254 -0
  28. package/src/direct-typescript-probe.mjs +27 -0
  29. package/src/direct-typescript.mjs +205 -0
  30. package/src/glob.mjs +8 -0
  31. package/src/history-worker.mjs +21 -13
  32. package/src/index.mjs +108 -21
  33. package/src/installed-inputs.mjs +7 -1
  34. package/src/installed-observation.mjs +23 -0
  35. package/src/landlock.py +527 -0
  36. package/src/linux-inotify.py +39 -10
  37. package/src/node-onboarding.mjs +203 -54
  38. package/src/node-script.mjs +406 -0
  39. package/src/npm-integrity.mjs +124 -0
  40. package/src/npm-typecheck-environment.mjs +36 -0
  41. package/src/npmrc.mjs +16 -0
  42. package/src/observation.mjs +34 -12
  43. package/src/path-identity.mjs +8 -0
  44. package/src/plan-worker.mjs +17 -17
  45. package/src/pnpm-inputs.mjs +122 -57
  46. package/src/pnpm-shim-templates.json +180 -0
  47. package/src/pnpm-shims.mjs +54 -0
  48. package/src/pnpm-typecheck-probe.mjs +11 -6
  49. package/src/presentation.mjs +13 -4
  50. package/src/probe-deadline.mjs +37 -0
  51. package/src/probe-result.mjs +7 -3
  52. package/src/project-location.mjs +34 -0
  53. package/src/python-onboarding.mjs +89 -0
  54. package/src/run-recorder.cjs +91 -0
  55. package/src/sandbox.mjs +659 -0
  56. package/src/setup.mjs +224 -0
  57. package/src/stats.mjs +72 -0
  58. package/src/test-direct.mjs +608 -0
  59. package/src/test-recorder.cjs +435 -0
  60. package/src/typescript-contract-probe.mjs +4 -187
  61. package/src/typescript-input-facts.mjs +92 -0
  62. package/src/typescript-invocation.mjs +92 -0
  63. package/src/typescript-list.mjs +118 -13
  64. package/src/typescript-qualification.mjs +29 -49
  65. package/src/workspace-manifests.mjs +24 -0
  66. package/src/yarn-workspace-inputs.mjs +3 -0
  67. 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,24 @@ 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
17
  import {atomicJson,readReceipts,readReceiptSnapshot,receiptRevision} from './state-store.mjs';
18
- import {history} from './observation.mjs';
19
- import {inputKey} from './decision-validation.mjs';
20
- import {timing} from './decision-profile.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';
28
+ import {isSandboxed,sandboxChange,visibleChange as sandboxVisibleChange} from './sandbox.mjs';
21
29
 
22
30
  const configFile=path.resolve(process.argv[2]);
23
31
  const config=JSON.parse(fs.readFileSync(configFile));
@@ -31,6 +39,7 @@ const endpoint=controlEndpoint(state),socket=endpoint.address,
31
39
  fs.mkdirSync(lock); // Existing lock requires operator inspection; never steal it.
32
40
  fs.writeFileSync(path.join(lock,'pid'),String(process.pid),{mode:0o600});
33
41
  const generation=randomUUID(), startedAt=Date.now();
42
+ traceBase({generation,work:'startup'});
34
43
  let index, receipts={}, watcher, healthy=false, reason='initial reconciliation', stopping=false;
35
44
  let receiptStateError=null;
36
45
  let receiptStateRevision=null;
@@ -44,6 +53,9 @@ let pending=new Set(), pendingChecks=new Set(), flushTimer, planDirty=false, pla
44
53
  let decisionRequests=0;
45
54
  let decisionSequence=0;
46
55
  let inputEventSerial=new Map();
56
+ // The last input-event paths per check, with their serials, so a test run can
57
+ // show that every event during it was one of its own outputs.
58
+ const inputEventPaths=new Map(),eventPathLimit=10000;
47
59
  let diagnosticInputEvents=0;
48
60
  let synchronizing=null;
49
61
  let installedRecheck=null;
@@ -52,7 +64,9 @@ let identityChanged=false;
52
64
  let identityGapKey=null;
53
65
  let triggerHashes=new Map();
54
66
  let candidateDescendants=new Map();
55
- let workspacePatterns=(()=>{const w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;
67
+ // A Python project may have no package.json.
68
+ let workspacePatterns=(()=>{let w;try{w=JSON.parse(fs.readFileSync(path.join(root,'package.json'))).workspaces;}
69
+ catch(e){if(e.code==='ENOENT')return [];throw e;}
56
70
  return Array.isArray(w)?w:w?.packages||[];})();
57
71
  let planningWorker=null;
58
72
  let validatedPlan=null;
@@ -110,62 +124,162 @@ function persistedPlan(bundle) {
110
124
  return [name,safe];
111
125
  }))};
112
126
  }
113
- function probe(row,allowReuse=false) {
127
+ // Continuity for in-process certificate reuse: every observed change is applied.
128
+ // macOS needs its history cursor; Linux has the synchronize barrier in catchUp.
129
+ // Windows reconciles content at each decision and keeps its own reuse path.
130
+ function continuityBarrier() {
131
+ return process.platform!=='win32'&&(process.platform!=='darwin'||hasHistory&&!historyDisabled)&&
132
+ healthy&&watcher&&!planning&&!recovering&&!planDirty&&!index?.unavailable&&
133
+ !pending.size&&!pendingChecks.size&&!external.size&&verifyRoot();
134
+ }
135
+ // The basis a full direct probe is checked against: plan guard, observation
136
+ // continuity, and the compiler tree and Node identity taken before the probe.
137
+ function certificateBasis(row) {
138
+ const argv=row.plan.probes[0],compiler=directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false});
139
+ return {guard:planGuard(index.bundle),gaps:metrics.gaps,
140
+ compiler:JSON.stringify([compiler.tsRoot,compiler.entry,compiler.digest]),
141
+ toolchain:toolchainKey(compiler.tsRoot,argv[0])};
142
+ }
143
+ // Returns null after reusing the last full probe, otherwise why a full probe is needed.
144
+ function reuseCertificate(row,key,allowReuse,forceFull,checked) {
145
+ const started=performance.now(),cache=row.probeCache;
146
+ if(forceFull)return 'forced_full_reference';
147
+ if(!allowReuse||!continuityBarrier())return 'continuity_unavailable';
148
+ if(row.plan.unresolved.length)return 'coverage_unresolved';
149
+ if(!cache?.queries?.length)return 'no_validated_result';
150
+ if(cache.input!==key)return 'inputs_changed';
151
+ if(cache.gaps!==metrics.gaps)return 'observation_gap';
152
+ let reason=null;
153
+ try{const basis=checked.basis=certificateBasis(row);
154
+ if(basis.guard!==cache.guard){planDirty=true;reason='plan_guard_changed';}
155
+ else if(basis.compiler!==cache.compiler||basis.toolchain!==cache.toolchain)reason='toolchain_changed';
156
+ else if(!queriesHold(cache.queries,cache.identities))reason='compiler_queries_changed';}
157
+ catch{reason='validation_unavailable';}
158
+ timing('daemon.probe_validation',started,{outcome:reason?'changed_or_unavailable':'reused',
159
+ reason:reason||'content_and_context_unchanged',queries:cache.queries.length});
160
+ if(reason)return reason;
161
+ row.probeHash=cache.hash;row.probeAt=Date.now();row.probeError=null;
162
+ metrics.probeReuses=(metrics.probeReuses||0)+1;
163
+ return null;
164
+ }
165
+ // The current full-probe deadline for a check; see probe-deadline.mjs. Hints
166
+ // are kept per check name, so a plan rebuild (each Windows decision) keeps them.
167
+ const probeHints=new Map();
168
+ function fullProbeDeadline(row) {
169
+ const run=receipts[row.plan.name]?.invocation;
170
+ return probeDeadline({floor:probeFloorMs(row.plan.qualification),hint:probeHints.get(row.plan.name),
171
+ runMs:run?.status==='exited'?run.durationMs:0});
172
+ }
173
+ function probe(row,allowReuse=false,{launch=true}={}) {
114
174
  const started=performance.now();
115
175
  const supported=new Map([
116
176
  ['typescript-noemit-v1','typescript-contract-probe.mjs'],
177
+ ['npm-typescript-direct-v1','direct-typescript-probe.mjs'],
117
178
  ['yarn-workspace-tsc-v1','yarn-workspace-typecheck-probe.mjs'],
118
179
  ['pnpm-tsc-v1','pnpm-typecheck-probe.mjs']]);
119
180
  const expected=supported.get(row.plan.qualification);
120
- const cacheable=process.platform==='win32'&&row.plan.probes.length===1&&expected&&
181
+ const builtIn=row.plan.probes.length===1&&expected&&
121
182
  row.plan.probes[0][1]===fileURLToPath(new URL(expected,import.meta.url));
183
+ const inProcess=builtIn&&process.platform!=='win32'&&row.plan.qualification==='npm-typescript-direct-v1';
184
+ const cacheable=builtIn&&(process.platform==='win32'||inProcess);
122
185
  const key=inputKey(row);
123
- if(cacheable&&allowReuse&&row.probeCache?.input===key){
186
+ const forceFull=process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_FULL_PROBES==='1';
187
+ let fullReason=null;const checked={};
188
+ if(inProcess){
189
+ fullReason=reuseCertificate(row,key,allowReuse,forceFull,checked);
190
+ if(!fullReason)return;
191
+ }
192
+ // Idle revalidation never launches a process. An unconfirmed result expires.
193
+ if(!launch&&row.plan.probes.length)return;
194
+ if(process.platform==='win32'&&cacheable&&allowReuse&&!forceFull&&row.probeCache?.input===key){
124
195
  const argv=row.plan.probes[0];
196
+ mark('daemon.probe_launch',{mode:'certificate',deadlineMs:10000});
125
197
  const out=spawnSync(argv[0],[...argv.slice(1),'--redue-context'],{
126
- cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
127
- if(!out.error&&!out.signal&&out.status===0&&
128
- out.stdout.toString()===row.probeCache.context){
198
+ cwd:row.plan.cwd,timeout:10000,maxBuffer:1024*1024,env:traceEnvironment(),stdio:['ignore','pipe','pipe']});
199
+ mark('daemon.probe_complete',{mode:'certificate',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
200
+ metrics.probeContextProcesses=(metrics.probeContextProcesses||0)+1;
201
+ if(!out.error&&!out.signal&&out.status===0&&out.stdout.toString()===row.probeCache.context){
129
202
  row.probeHash=row.probeCache.hash;row.probeAt=Date.now();row.probeError=null;
130
203
  metrics.probeReuses=(metrics.probeReuses||0)+1;
131
204
  timing('daemon.probe_context_validation',started,{outcome:'reused',reason:'content_and_context_unchanged'});
132
205
  return;
133
206
  }
207
+ fullReason='context_or_compiler_queries_changed';
134
208
  timing('daemon.probe_context_validation',started,{outcome:'changed_or_unavailable'});
135
209
  }
136
- timing('daemon.probe_reason',started,{reason:!cacheable?'generic_probe':
137
- !row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed'});
210
+ fullReason??=forceFull?'forced_full_reference':!cacheable?'generic_probe':
211
+ !row.probeCache?'no_validated_result':row.probeCache.input!==key?'inputs_changed':'context_changed';
212
+ if(row.plan.probes.length){
213
+ timing('daemon.probe_reason',started,{reason:fullReason});
214
+ metrics.fullProbeReasons??={};metrics.fullProbeReasons[fullReason]=(metrics.fullProbeReasons[fullReason]||0)+1;
215
+ metrics.fullProbes=(metrics.fullProbes||0)+1;
216
+ }
217
+ // A failed or incomplete full probe must not leave an older certificate usable.
218
+ let basis=checked.basis||null;
219
+ if(inProcess){
220
+ if(!basis)try{basis=certificateBasis(row);}catch{}
221
+ if(row.probeCache?.guard&&basis&&basis.guard!==row.probeCache.guard)planDirty=true;
222
+ row.probeCache=null;
223
+ }
138
224
  const values=[];let checkpoint=null;
225
+ // The slowest of this check's probes sets the hint for all of them.
226
+ const deadline=fullProbeDeadline(row);let hint=0;
227
+ const learn=()=>{if(hint)probeHints.set(row.plan.name,hint);};
139
228
  for(const argv of row.plan.probes) {
140
- // Supported TypeScript probes perform bounded input/toolchain hashing.
141
- // Native NTFS runs have exceeded the generic 5 s cap under load, despite
142
- // completing normally and with the duplicated work already removed.
143
- const timeout=row.plan.qualification==='pnpm-tsc-v1'||
144
- (process.platform==='win32'&&['typescript-noemit-v1','yarn-workspace-tsc-v1']
145
- .includes(row.plan.qualification))?10000:5000;
146
- const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[])],{cwd:row.plan.cwd,
147
- timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe']});
229
+ const timeout=deadline,launched=performance.now();
230
+ mark('daemon.probe_launch',{mode:'full',reason:fullReason,deadlineMs:timeout});
231
+ // A built-in probe gets its deadline, so its inner compiler calls share it.
232
+ const out=spawnSync(argv[0],[...argv.slice(1),...(cacheable?['--redue-checkpoint']:[]),
233
+ ...(builtIn?[`--redue-deadline=${timeout}`]:[])],{cwd:row.plan.cwd,
234
+ timeout,maxBuffer:1024*1024,stdio:['ignore','pipe','pipe'],env:traceEnvironment()});
235
+ mark('daemon.probe_complete',{mode:'full',childPid:out.pid,exit:out.status,signal:out.signal,code:out.error?.code});
236
+ const timedOut=out.error?.code==='ETIMEDOUT';
237
+ if(!out.error||timedOut)hint=Math.max(hint,nextProbeHint({durationMs:performance.now()-launched,deadlineMs:timeout,timedOut}));
148
238
  if(out.error||out.signal||out.status!==0) {
239
+ learn();
149
240
  if(!row.probeError){row.revision++;row.lastReason='declared state probe became unavailable';}
150
241
  row.probeHash=null;row.probeAt=0;
151
- const code=['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
242
+ const code=['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
152
243
  .includes(row.plan.qualification)?
153
244
  /VSTATE_REASON:([a-z-]+)/.exec(out.stderr?.toString()||'')?.[1]:null;
154
245
  const explanations={
246
+ 'direct-typescript-implementation-unreviewed':'installed TypeScript files differ from the reviewed compiler or from the tarball package-lock.json names',
247
+ 'direct-typescript-version-unsupported':'direct compiler recipe supports TypeScript 5.x and 6.0',
248
+ '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)',
249
+ 'npm-shrinkwrap-unsupported':'npm-shrinkwrap.json is not read; the reviewed TypeScript releases still qualify',
250
+ 'direct-typescript-lockfile-integrity-missing':'package-lock.json records no integrity for the installed TypeScript',
251
+ 'direct-typescript-lockfile-version-differs':'installed TypeScript version differs from package-lock.json; run npm ci',
252
+ 'npm-lockfile-unreadable':'package-lock.json cannot be read',
253
+ 'npm-lockfile-integrity-unsupported':'package-lock.json records TypeScript integrity with neither sha512 nor sha1',
254
+ '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`,
255
+ 'npm-tarball-corrupt':"npm's cached TypeScript tarball does not match its integrity",
256
+ 'npm-tarball-invalid':"npm's cached TypeScript tarball cannot be read",
257
+ 'npm-tarball-link-unsupported':"npm's cached TypeScript tarball holds links",
258
+ 'direct-execution-environment-unsupported':'direct compiler rejects development helpers, Node cache/preloads, and unreviewed runtime overrides',
259
+ 'direct-node-version-unsupported':'direct compiler recipe supports Node 22 and 24',
260
+ 'npm-launcher-unobserved-inputs':'npm launcher uses unobserved notifier inputs; execution is recording-only',
155
261
  'typecheck-script-changed':'typecheck script or lifecycle changed; rerun init or review it',
156
262
  'execution-environment-unsupported':'Node/npm preload or configuration environment is unsupported',
157
263
  'typescript-executable-changed':'local TypeScript executable changed or is missing',
158
264
  'typescript-config-unavailable':'TypeScript configuration cannot be resolved',
159
265
  'typescript-config-graph-unsupported':'TypeScript configuration extends, references, or uses compiler features outside this contract',
160
- 'typescript-incremental-unsupported':'TypeScript incremental or composite compilation needs explicit review',
266
+ 'typescript-incremental-unsupported':'TypeScript composite compilation needs explicit review',
267
+ 'typescript-buildinfo-outside-repository':'TypeScript incremental build-info file is outside the observed repository',
268
+ 'typescript-incremental-unsound':'TypeScript assumeChangesOnlyAffectDirectDependencies makes an incremental result depend on build history',
269
+ 'typescript-buildinfo-name-unsupported':'TypeScript build-info file needs a plain name ending in .tsbuildinfo',
270
+ 'typescript-project-reference-consumed':'TypeScript program uses files of a referenced project, which plain tsc does not build',
161
271
  'typescript-config-outside-repository':'TypeScript configuration extends outside the observed repository',
162
272
  'typescript-plugin-unsupported':'TypeScript compiler plugin needs explicit review',
163
273
  'typescript-input-list-unavailable':'TypeScript input listing did not complete',
164
274
  'typescript-input-outside-repository':'TypeScript consumes files outside the observed repository',
275
+ 'typescript-vite-cache-consumed':'TypeScript asks about node_modules/.vite or lists node_modules; Vitest rewrites files there and this recipe does not observe them',
165
276
  'npm-installation-layout-unsupported':'npm executable or installation layout is unsupported',
166
277
  'npm-installation-unobservable':'npm installation contains unobservable entries',
167
278
  'npm-configuration-unsupported':'npm script configuration needs review',
168
279
  'typescript-contract-unavailable':'TypeScript applicability probe could not establish inputs',
280
+ 'npm-proxy-version-unsupported':'npm proxy context is reviewed only for npm 10.9.2',
281
+ 'npm-proxy-context-ambiguous':'conflicting npm proxy environment keys require resolution',
282
+ 'npm-proxy-value-unsupported':'npm proxy environment contains an unsupported value',
169
283
  'workspace-execution-environment-unsupported':'Yarn/Node launcher environment override needs review',
170
284
  'yarn-toolchain-unavailable':'pinned Yarn version or executable is unavailable',
171
285
  'yarn-distribution-unobservable':'Corepack Yarn distribution cannot be inspected',
@@ -173,7 +287,7 @@ function probe(row,allowReuse=false) {
173
287
  'workspace-typecheck-contract-unavailable':'workspace TypeScript applicability probe could not establish inputs'
174
288
  ,'pnpm-execution-environment-unsupported':'pnpm or Node launcher environment override needs review'
175
289
  ,'pnpm-distribution-unobservable':'pnpm executable contents cannot be observed'
176
- ,'pnpm-version-mismatch':'pnpm executable differs from the pinned version'
290
+ ,'pnpm-version-mismatch':'pnpm executable differs from the installed version'
177
291
  ,'typescript-input-outside-pnpm-contract':'TypeScript reads outside the selected workspace, linked outputs, or installed inputs'
178
292
  ,'pnpm-input-coverage-unavailable':'pnpm installed-input coverage became unavailable; inspect check details'
179
293
  ,'pnpm-typecheck-contract-unavailable':'pnpm TypeScript input contract could not be established'
@@ -187,18 +301,26 @@ function probe(row,allowReuse=false) {
187
301
  try{checkpoint=JSON.parse(out.stdout);
188
302
  checkpoint.queries=checkpoint.queryData?JSON.parse(inflateSync(
189
303
  Buffer.from(checkpoint.queryData,'base64'),{maxOutputLength:8*1024*1024})):null;
304
+ // Optional: without a layout, a run after an edit falls back to a full probe.
305
+ try{checkpoint.layout=checkpoint.layoutData?JSON.parse(inflateSync(
306
+ Buffer.from(checkpoint.layoutData,'base64'),{maxOutputLength:8*1024*1024})):null;}
307
+ catch{checkpoint.layout=null;}
190
308
  if(!/^[a-f0-9]{64}$/.test(checkpoint.output)||!/^[a-f0-9]{64}$/.test(checkpoint.context))
191
309
  throw Error('invalid supported probe checkpoint');}
192
310
  catch{row.probeHash=null;row.probeAt=0;row.probeError='probe checkpoint unavailable';return;}
193
311
  }
194
312
  values.push([argv,out.status,sha(cacheable?checkpoint.output:out.stdout),sha(out.stderr)]);
195
313
  }
314
+ learn();
196
315
  const value=sha(JSON.stringify(values));
197
316
  if((row.probeHash!==null&&row.probeHash!==value)||row.probeError){
198
317
  row.revision++;row.lastReason='declared state probe changed or recovered';}
199
318
  row.probeHash=value;row.probeAt=Date.now();row.probeError=null;
200
- if(checkpoint)row.probeCache=checkpoint.queries?{input:key,context:checkpoint.context,
201
- hash:value,queries:checkpoint.queries}:null;
319
+ if(checkpoint)row.probeCache=checkpoint.queries&&(!inProcess||basis)?{input:key,context:checkpoint.context,
320
+ hash:value,queries:checkpoint.queries,...(inProcess?{schema:1,...basis,
321
+ identities:new Map(),
322
+ output:checkpoint.output,queryData:checkpoint.queryData,layout:checkpoint.layout,
323
+ stderrHash:values[0][3]}: {})}:null;
202
324
  timing('daemon.probe',started,{outcome:'ok',commands:values.length,
203
325
  reusable:row.probeCache?1:0,queries:row.probeCache?.queries.length||0});
204
326
  }
@@ -226,6 +348,18 @@ function describeChangedInput(plan,file) {
226
348
  .map(mapping=>mapping.logical).slice(0,2);
227
349
  return file+' changed'+(matches.length?` (installed via ${matches.join(', ')})`:'');
228
350
  }
351
+ // A run that hid the checkout's git-ignored entries (sandbox@2), and maybe
352
+ // documentation files (sandbox@3, the receipt's sandbox.docs): the first
353
+ // changed input it could see (sandbox.mjs's visibleChange), or null; once
354
+ // per receipt and pair of fingerprints.
355
+ const hiddenRun=r=>Array.isArray(r?.sandbox?.hidden)&&Boolean(r.files);
356
+ function visibleChange(row,r,name) {
357
+ const key=(r.invocation?.runId||'')+'\0'+r.fingerprint+'\0'+combinedSummary(name).fingerprint;
358
+ if(row.visibleKey!==key){const current=combinedFiles(name);
359
+ row.visibleKey=key;row.visibleChange=sandboxVisibleChange(root,r.sandbox.hidden,r.files,current,[...Object.keys(r.files),...Object.keys(current)],
360
+ {docs:Array.isArray(r.sandbox.docs)?r.sandbox.docs:null});}
361
+ return row.visibleChange;
362
+ }
229
363
  function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
230
364
  if(!index)return [];
231
365
  return [...index.checks].map(([name,row])=>{
@@ -233,7 +367,10 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
233
367
  const item={name,result,freshness:'UNVERIFIED',reason:'no receipt',changed_inputs:[],
234
368
  plan_id:row.plan.id,observed_at:lastObservation,
235
369
  invocation:r?.invocation||null,target_provenance:r?.target||null,
236
- coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false};
370
+ ...(r?.verificationRecipe?{verification_recipe:r.verificationRecipe}:{}),
371
+ ...(r?.recipe?{recipe:r.recipe}:{}),
372
+ coverage_at_run:r?.coverage||null,declared_inputs_match:null,reuse_eligible:false,
373
+ ...(r?.runFacts?.length?{run_facts:r.runFacts.length}:{})};
237
374
  if(receiptStateError){item.reason=receiptStateError;return item;}
238
375
  if(!healthy||planning||recovering||index.unavailable){item.reason=
239
376
  (planning||recovering)?'input observation reconciliation pending':
@@ -243,8 +380,9 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
243
380
  return item;}
244
381
  if(pendingChecks.has(name)){item.reason='filesystem change pending content inspection';return item;}
245
382
  if(r&&r.planId) item.declared_inputs_match=r.planId===row.plan.id&&
246
- r.fingerprint===combinedSummary(name).fingerprint;
247
- if(r&&r.fingerprintVersion!==1){item.reason='receipt uses earlier input-resolution format';return item;}
383
+ (r.fingerprint===combinedSummary(name).fingerprint||hiddenRun(r)&&visibleChange(row,r,name)===null);
384
+ if(r&&!(r.fingerprintVersion===1||r.fingerprintVersion===2&&Array.isArray(r.runFacts))){
385
+ item.reason='receipt uses earlier input-resolution format';return item;}
248
386
  if(row.plan.unresolved.length){
249
387
  const checkpoint=item.declared_inputs_match===false?
250
388
  r.planId!==row.plan.id?'; observed input plan changed':
@@ -253,7 +391,13 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
253
391
  item.reason='input coverage unresolved: '+row.plan.unresolved.join('; ')+checkpoint+probeIssue;
254
392
  return item;
255
393
  }
256
- if(row.probeError||!row.probeAt||Date.now()-row.probeAt>10000){item.reason=row.probeError||'state probe observation expired';return item;}
394
+ if(row.probeError){item.reason=row.probeError;return item;}
395
+ // The plan leaves out the build-info file it saw at start; the probe sees the current one.
396
+ if(row.probeCache?.layout&&(row.probeCache.layout.buildInfo||null)!==(row.plan.buildInfo||null)){
397
+ item.reason='the incremental build-info setting changed after the observer started; restart the observer';return item;}
398
+ // An expired probe or missing execution context cannot confirm CURRENT, but
399
+ // observed input content that already differs from the receipt is STALE.
400
+ let unconfirmed=!row.probeAt||Date.now()-row.probeAt>10000?'state probe observation expired':null;
257
401
  if(row.plan.synchronizedInstalledRead&&!synchronizedInstalled){
258
402
  item.reason='installed inputs require a synchronized content read';return item;}
259
403
  if(process.platform==='win32'&&!synchronizedFilesystem){
@@ -266,15 +410,21 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
266
410
  item.reason=`target execution ${r.target?.status||'unavailable'}`;return item;}
267
411
  if(r.target.status==='failed'&&result!=='FAIL'){
268
412
  item.reason='target failure conflicts with outer invocation outcome';return item;}
269
- if(!r.stable){item.reason='inputs changed during execution or observation gap';return item;}
413
+ if(!r.stable){const issues=r.checkpoint?.issues?.length?` (${r.checkpoint.issues.join('; ')})`:'';
414
+ // A sandboxed run that could not start, whose result the sandbox changed, or whose rerun outside it did not finish, says so alone.
415
+ const sandbox=r.checkpoint?.issues?.length&&r.checkpoint.issues.every(issue=>/^the (?:sandbox changed|run was not sandboxed|run outside the sandbox|network supervisor)/.test(issue));
416
+ item.reason=sandbox?r.checkpoint.issues.join('; '):`inputs changed during execution or observation gap${issues}`;return item;}
270
417
  const names=[...(row.plan.environment.variables||[]),
271
418
  ...(row.plan.environment.prefixes||[]).map(prefix=>
272
419
  `__vstate_env_prefix_${prefix.toLowerCase()}__`),
273
420
  ...(row.plan.environment.pathExecutables||[]).map(name=>
274
421
  `__vstate_path_executable_${name}__`),
275
422
  ...(row.plan.environment.executableIdentity?['__vstate_node_identity__']:[])];
276
- if(names.length&&!contextHashes?.[name]){
277
- item.reason='execution environment context not supplied';return item;}
423
+ // A sandboxed run's variables, toolchain, PATH directories and places
424
+ // (sandbox.mjs): every entry either side has, and the first change named.
425
+ const sandboxChanged=isSandboxed(row.plan.qualification)&&contextHashes?.[name]?
426
+ sandboxChange(r?.environmentHashes,contextHashes[name]):null;
427
+ if(names.length&&!contextHashes?.[name])unconfirmed??='execution environment context not supplied';
278
428
  item.freshness='STALE';
279
429
  if(r.planId!==row.plan.id){
280
430
  const before=new Map((r.resolutionLinks||[]).map(link=>[link.path,link.targetHash]));
@@ -282,10 +432,21 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
282
432
  item.reason=changed?`installed input link changed: ${changed.path}`:'input plan changed';
283
433
  if(changed)item.changed_inputs=[changed.path];
284
434
  }
285
- else if(names.some(key=>contextHashes[name]?.[key]!==r.environmentHashes?.[key]))
435
+ else if(sandboxChanged)item.reason=sandboxChanged;
436
+ else if(contextHashes?.[name]&&names.some(key=>contextHashes[name][key]!==r.environmentHashes?.[key]))
286
437
  item.reason='declared execution environment changed';
287
- else if(r.fingerprint!==combinedSummary(name).fingerprint) {
438
+ // Every variable a test run read directly must have the same value. The
439
+ // caller compares them and names the list it used; another list confirms nothing.
440
+ else if(r.environmentReads&&contextHashes?.[name]&&
441
+ (contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))||contextHashes[name].__redue_env_changed__)){
442
+ if(contextHashes[name].__redue_env_reads__!==sha(JSON.stringify(r.environmentReads))){
443
+ item.freshness='UNVERIFIED';item.reason='environment variables the last run read were not compared';}
444
+ else item.reason=`environment variable ${contextHashes[name].__redue_env_changed__} differs from the last run`;
445
+ }
446
+ else if(r.fingerprint!==combinedSummary(name).fingerprint&&!(hiddenRun(r)&&visibleChange(row,r,name)===null)) {
288
447
  const key=r.fingerprint+'\0'+combinedSummary(name).fingerprint;
448
+ if(row.staleReasonKey!==key&&hiddenRun(r)){
449
+ row.staleInputs=[visibleChange(row,r,name)];row.staleReason=describeChangedInput(row.plan,row.staleInputs[0]);row.staleReasonKey=key;}
289
450
  if(row.staleReasonKey!==key){
290
451
  let changed=row.lastReason;row.staleInputs=[];
291
452
  const candidate=changed?.endsWith(' changed')?changed.slice(0,-8):null;
@@ -297,30 +458,98 @@ function rows(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=f
297
458
  if(r.files[f]!==current[f]){changed=describeChangedInput(row.plan,f);row.staleInputs=[f];break;}
298
459
  }
299
460
  row.staleReason=changed||
300
- (['typescript-noemit-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
461
+ (['typescript-noemit-v1','npm-typescript-direct-v1','yarn-workspace-tsc-v1','pnpm-tsc-v1']
301
462
  .includes(row.plan.qualification)?
302
463
  'TypeScript input set, generated file, or toolchain context changed':
303
464
  'declared input content or membership changed');
304
465
  row.staleReasonKey=key;
305
466
  }
306
467
  item.reason=row.staleReason;item.changed_inputs=row.staleInputs||[];
468
+ if(unconfirmed&&!(r.files&&row.staleInputs?.length)){item.freshness='UNVERIFIED';
469
+ item.reason=unconfirmed;item.changed_inputs=[];}
470
+ } else if(unconfirmed){item.freshness='UNVERIFIED';item.reason=unconfirmed;
471
+ // Lookups the run made outside the certificate are asked again for every
472
+ // answer, cached or synchronized, before it can say CURRENT.
473
+ // A direct run recorded before run facts may have made lookups no probe
474
+ // asks; only Windows, which never records direct runs, keeps reusing them.
475
+ } else if(r.fingerprintVersion===1&&r.verificationRecipe==='npm-direct-typescript@1'&&process.platform!=='win32'){
476
+ item.freshness='UNVERIFIED';item.reason='receipt predates run facts; run the check again';
477
+ } else if(r.runFacts&&(item.reason=runFactReason(r.runFacts))){item.changed_inputs=[];
478
+ // A recorded test run that used something REDUE does not observe, or
479
+ // whose result differed from an earlier run on the same inputs.
480
+ } else if(r.guardIssues?.length){item.freshness='UNVERIFIED';
481
+ item.reason=`the run used inputs REDUE does not observe: ${r.guardIssues.slice(0,3).join('; ')}`+
482
+ (r.guardIssues.length>3?`; and ${r.guardIssues.length-3} more`:'');
483
+ } else if(r.nondeterministic){item.freshness='UNVERIFIED';item.reason='runs on these exact inputs had different results';
307
484
  } else {item.freshness='CURRENT';item.reason=result==='FAIL'?'last command failed':'declared inputs match';
308
485
  item.reuse_eligible=result==='PASS';}
309
486
  return item;
310
487
  });
311
488
  }
489
+ // Null when every run fact in a receipt answers as it did during the run.
490
+ function runFactReason(facts) {
491
+ const started=performance.now(),reason=runFactChanged(facts);
492
+ timing('daemon.run_facts',started,{facts:facts.length,changed:reason?1:0});
493
+ return reason;
494
+ }
495
+ // The indexed inputs already differ from the latest receipt, so no probe result
496
+ // can make this check CURRENT. A sync answers STALE without a full probe. A run
497
+ // probes, or builds the certificate from its recording (adopt), and a revert
498
+ // makes the inputs match and probes again.
499
+ function knownStale(name,row) {
500
+ const r=receipts[name];
501
+ if(!r?.files||r.planId!==row.plan.id||row.probeError||pendingChecks.has(name)||
502
+ row.probeCache?.input===inputKey(row))return false;
503
+ const current=combinedFiles(name),files=Object.keys(current);
504
+ return files.length!==Object.keys(r.files).length||files.some(file=>r.files[file]!==current[file]);
505
+ }
506
+ // A run can rebuild a certificate that no longer applies only because indexed
507
+ // inputs changed: same plan guard, compiler, toolchain and observation.
508
+ function adoptable(row) {
509
+ const cache=row.probeCache;
510
+ if(process.platform==='win32'||row.plan.qualification!=='npm-typescript-direct-v1'||
511
+ row.plan.unresolved.length||row.probeError||cache?.schema!==1||!cache.layout||!cache.queries?.length||
512
+ cache.input===inputKey(row)||cache.gaps!==metrics.gaps||!continuityBarrier())return false;
513
+ try{const basis=certificateBasis(row);
514
+ return basis.guard===cache.guard&&basis.compiler===cache.compiler&&basis.toolchain===cache.toolchain;}
515
+ catch{return false;}
516
+ }
517
+ // The end checkpoint of a run that started without a certificate. Its recorded
518
+ // answers become the candidate certificate when nothing observed changed during
519
+ // the run. The probe that follows validates it, or runs in full as before.
520
+ function adopt(name,row,token) {
521
+ const started=performance.now(),pending=row.adoption,file=path.join(state,`run-record-${token}.json`);
522
+ row.adoption=null;
523
+ let text=null;
524
+ try{text=fs.readFileSync(file,'utf8');}catch{}
525
+ const done=issue=>{timing('daemon.recorded_certificate',started,{outcome:issue?'rejected':'accepted',
526
+ ...(issue?{reason:issue}:{})});return issue;};
527
+ if(!pending||pending.token!==token||text===null)return done('recording unavailable');
528
+ if(inputEventSerial.get(name)!==pending.serial||metrics.gaps!==pending.gaps||inputKey(row)!==pending.input||
529
+ row.probeCache!==pending.cache||row.probeHash!==null)
530
+ return done('inputs or certificate changed during the run');
531
+ const cache=pending.cache,argv=row.plan.probes[0];
532
+ let result;
533
+ try{result=recordedCertificate(text,cache,directProject(argv[2],runFromArgv(argv.slice(3)),{verify:false}),buildInfoPath(row.plan));}
534
+ catch{return done('recording unreadable');}
535
+ if(result.issue)return done(result.issue);
536
+ const identities=new Map(cache.identities);
537
+ for(const changed of result.changed)identities.delete(changed);
538
+ row.probeCache={...cache,hash:directProbeValue(argv,result.output,cache.stderrHash),
539
+ queries:result.queries,identities,output:result.output,layout:result.layout,input:pending.input,recorded:token,
540
+ queryData:deflateSync(JSON.stringify(result.queries)).toString('base64')};
541
+ metrics.recordedCertificates=(metrics.recordedCertificates||0)+1;
542
+ return done(null);
543
+ }
312
544
  function status(contextHashes,synchronizedInstalled=false,synchronizedFilesystem=false) {
313
545
  // Reload notifications accelerate visibility but are not required for it: a
314
546
  // wrapper may persist its outcome even when its control connection is denied.
315
547
  refreshReceipts();
316
- const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem), current=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='PASS').length,
317
- failed=checks.filter(r=>r.freshness==='CURRENT'&&r.result==='FAIL').length,
318
- observedFailed=checks.filter(r=>r.result==='FAIL').length,
319
- stale=checks.filter(r=>r.freshness==='STALE').length,
320
- unverified=checks.filter(r=>r.freshness==='UNVERIFIED').length||(!checks.length?1:0);
321
- const stateName=failed?'failed':unverified?'unverified':stale?'stale':'current';
322
- const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,state:stateName,current,failed,observed_failed:observedFailed,
323
- stale,unverified,checks,
548
+ const checks=rows(contextHashes,synchronizedInstalled,synchronizedFilesystem);
549
+ // A decision may run every check's probes in turn: callers wait for all of them.
550
+ const probing=[...(index?.checks.values()||[])].filter(row=>row.plan.probes.length);
551
+ const now=Date.now();return {schema:1,receipt_revision:receiptStateRevision,...statusSummary(checks),checks,
552
+ ...(probing.length?{probe_deadline_ms:probing.reduce((sum,row)=>sum+fullProbeDeadline(row)*row.plan.probes.length,0)}:{}),
324
553
  observation:{healthy:healthy&&!planning&&!recovering&&!index?.unavailable,
325
554
  generation,observed_at:lastObservation,
326
555
  events:(index?.eventCount||0)+[...external.values()].reduce((n,e)=>n+(e.index?.eventCount||0),0),
@@ -355,16 +584,10 @@ function hydrate(bundle,saved) {
355
584
  }
356
585
  next.eventCount=saved.eventCount;next.rehashedFiles=saved.rehashedFiles;
357
586
  next.rehashedBytes=saved.rehashedBytes;next.profile=saved.profile;
587
+ next.racy=new Map(saved.racy||[]);
358
588
  return next;
359
589
  }
360
- function dehydrate(value) {
361
- return {files:[...value.files],
362
- aliasDependencies:[...value.aliasDependencies].map(([name,refs])=>[name,[...refs]]),
363
- rows:[...value.checks].map(([name,row])=>({name,files:[...row.files],
364
- sum:row.sum.toString('hex'),revision:row.revision,lastReason:row.lastReason})),
365
- eventCount:value.eventCount,rehashedFiles:value.rehashedFiles,
366
- rehashedBytes:value.rehashedBytes,profile:value.profile};
367
- }
590
+ const dehydrate=dehydrateIndex;
368
591
  function retainIndex() {
369
592
  if(!index||!healthy||planning||pending.size||planDirty||historyDisabled||
370
593
  !fs.existsSync(snapshotPath))return;
@@ -384,7 +607,7 @@ function buildInWorker(forceCold=false,reuse=null) {
384
607
  return new Promise((resolve,reject)=>{
385
608
  const workerStarted=performance.now();
386
609
  const file=path.join(path.dirname(fileURLToPath(import.meta.url)),'plan-worker.mjs');
387
- const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,
610
+ const worker=new Worker(file,{workerData:{config:configFile,state,forceCold,reuse,diagnostic:traceContext(),
388
611
  reconcileFaultFile:process.env.VSTATE_TEST_RECONCILE_FAULT_FILE}});
389
612
  planningWorker=worker;let settled=false;
390
613
  const deadline=setTimeout(()=>{
@@ -409,7 +632,7 @@ function buildInWorker(forceCold=false,reuse=null) {
409
632
  reject(Error(`input-plan worker exited ${code}`));}});
410
633
  });
411
634
  }
412
- async function refreshPlan({forceCold=false,reuse=false}={}) {
635
+ async function refreshPlan({forceCold=false,reuse=false,skip=null}={}) {
413
636
  if(planning||stopping)return;
414
637
  const totalStarted=performance.now();
415
638
  const previousProbes=new Map([...(index?.checks||[])].map(([name,row])=>[name,row.probeCache]));
@@ -492,7 +715,8 @@ async function refreshPlan({forceCold=false,reuse=false}={}) {
492
715
  atomic(path.join(state,'plans-v1.json'),persistedPlan(bundle));
493
716
  metrics.lastPlanPersistenceMs=performance.now()-persistenceStarted;
494
717
  const probeStarted=performance.now();
495
- for(const row of index.checks.values())probe(row,built.planReused);
718
+ if(skip)refreshReceipts();
719
+ for(const [name,row] of index.checks)if(!skip?.(name,row))probe(row,built.planReused);
496
720
  timing('daemon.plan_probes',probeStarted,{checks:index.checks.size});
497
721
  lastObservation=Date.now();
498
722
  event(built.planReused?'plan_validated':'plan_rebuilt',{checks:Object.keys(bundle.plans),workspace_count:bundle.workspaceCount});
@@ -588,14 +812,25 @@ function pnpmTopologyChanged(type,rel) {
588
812
  return stat.isDirectory()||stat.isSymbolicLink();}
589
813
  catch(e){return e.code==='ENOENT';}
590
814
  }
591
- function markInputEvent(name,rel,type) {
815
+ function markInputEvent(name,rel,type,replay) {
592
816
  pendingChecks.add(name);
593
- inputEventSerial.set(name,(inputEventSerial.get(name)||0)+1);
817
+ // macOS delivers each edit twice: live, and again from history replay. The
818
+ // live copy can arrive late, inside a later run. With history, count only the
819
+ // replay copy: the history cursor places it in the right interval.
820
+ if(!hasHistory||historyDisabled||replay){
821
+ const serial=(inputEventSerial.get(name)||0)+1;
822
+ inputEventSerial.set(name,serial);
823
+ // Kept only for recorded test and script checks and sandboxed ones; trimmed in batches.
824
+ if(['npm-tests-direct-v1','node-script-v1'].includes(index?.checks.get(name)?.plan.qualification)||isSandboxed(index?.checks.get(name)?.plan.qualification)){
825
+ const list=inputEventPaths.get(name)||[];list.push([serial,rel]);
826
+ if(list.length>2*eventPathLimit)list.splice(0,list.length-eventPathLimit);inputEventPaths.set(name,list);
827
+ }
828
+ }
594
829
  // Disposable public CI only. Never enabled for normal/local repositories.
595
830
  if(process.env.REDUE_PUBLIC_INPUT_TRACE==='1'&&rel&&diagnosticInputEvents++<5000)
596
- event('public_input_notification',{path:rel,type:type||'unknown',planning});
831
+ event('public_input_notification',{path:rel,type:type||'unknown',planning,replay});
597
832
  }
598
- function notification(type,filename) {
833
+ function notification(type,filename,replay=false) {
599
834
  metrics.notifications++;
600
835
  metrics.lastNotificationAt=Date.now();
601
836
  if(!filename){observationGap('unnamed_event');return;}
@@ -603,7 +838,7 @@ function notification(type,filename) {
603
838
  if(rel==='.git'||rel.startsWith('.git/'))return;
604
839
  if(planning) {
605
840
  if(index)for(const name of index.candidates(rel))
606
- if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
841
+ if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
607
842
  pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
608
843
  if(planTrigger(rel)||candidateDescendants.has(rel)||pnpmTopologyChanged(type,rel))
609
844
  planDirty=true;
@@ -627,9 +862,9 @@ function notification(type,filename) {
627
862
  pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,pending.size);
628
863
  if(index) {
629
864
  for(const name of index.candidates(rel))
630
- if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type);
865
+ if(index.matches(index.checks.get(name),rel))markInputEvent(name,rel,type,replay);
631
866
  for(const [prefix,names] of index.prefixes)
632
- 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,rel,type,replay);
633
868
  }
634
869
  if(pending.size>50000){healthy=false;reason='observation backlog overflow';metrics.gaps++;
635
870
  event('observation_gap',{classification:'backlog_overflow'});publish();
@@ -638,23 +873,23 @@ function notification(type,filename) {
638
873
  if(planDirty){healthy=false;reason='input plan possibly changed';}
639
874
  if(planDirty||pendingChecks.size)publish();
640
875
  }
641
- function externalNotification(entry,type,filename) {
876
+ function externalNotification(entry,type,filename,replay=false) {
642
877
  metrics.notifications++;
643
878
  metrics.lastNotificationAt=Date.now();
644
879
  if(!filename){externalGap(entry,'unnamed_event');return;}
645
880
  const rel=filename.toString().split(path.sep).join('/');
646
881
  const absolute=path.join(entry.root,rel);
647
882
  if(planning&&entry.index)for(const name of entry.index.candidates(rel))
648
- if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
883
+ if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
649
884
  if(planTrigger(absolute)&&triggerChanged(absolute))planDirty=true;
650
885
  if(candidateTriggerChanged(absolute))planDirty=true;
651
886
  entry.pending.add(rel);metrics.maxQueue=Math.max(metrics.maxQueue,
652
887
  pending.size+[...external.values()].reduce((n,e)=>n+e.pending.size,0));
653
888
  if(entry.index) {
654
889
  for(const name of entry.index.candidates(rel))
655
- if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,rel,type);
890
+ if(entry.index.matches(entry.index.checks.get(name),rel))markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
656
891
  for(const [prefix,names] of entry.index.prefixes)
657
- if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,rel,type);
892
+ if(prefix.startsWith(rel+'/'))for(const name of names)markInputEvent(name,`external:${entry.root}:${rel}`,type,replay);
658
893
  }
659
894
  if(planDirty){healthy=false;reason='input plan possibly changed';}
660
895
  publish();clearTimeout(flushTimer);flushTimer=setTimeout(flush,60);
@@ -672,7 +907,8 @@ function observationGap(classification,error) {
672
907
  // Once continuity is uncertain, a cursor is no longer authority. Do not
673
908
  // retry native history in this observer generation after reconciliation.
674
909
  historyDisabled=true;
675
- healthy=false;reason=`observation unavailable: ${classification}; deterministic reconciliation pending`;
910
+ const cause=error?.message?` (${error.message})`:'';
911
+ healthy=false;reason=`observation unavailable: ${classification}${cause}; deterministic reconciliation pending`;
676
912
  metrics.gaps++;
677
913
  event('observation_gap',{classification,error_code:error?.code||null});publish();
678
914
  // One bounded attempt per gap; a later independent gap may try again.
@@ -758,14 +994,14 @@ async function catchUp() {
758
994
  lastObservation=Date.now();return;
759
995
  }
760
996
  if(historyDisabled)return;
761
- if(synchronizing)return synchronizing;
997
+ if(synchronizing){mark('daemon.catch_up_join');return synchronizing;}
762
998
  synchronizing=(async()=>{
763
999
  if(!verifyRoot())throw Error('checkout identity changed');
764
1000
  if(!watcher)throw Error('subscription unavailable');
765
1001
  if(!fs.existsSync(snapshotPath))throw Error('historical cursor missing');
766
1002
  {
767
1003
  const changes=await queryHistory(root,snapshotPath,'root replay');
768
- for(const change of changes)notification(change.type,path.relative(root,change.path));
1004
+ for(const change of changes)notification(change.type,path.relative(root,change.path),true);
769
1005
  if(!planning&&(flushTimer||pending.size||planDirty))flush();
770
1006
  for(const entry of external.values())if(entry.index) {
771
1007
  try{
@@ -775,7 +1011,7 @@ async function catchUp() {
775
1011
  if(!entry.watcher||!fs.existsSync(entry.snapshot))throw Error('external cursor unavailable');
776
1012
  const changes=await queryHistory(entry.root,entry.snapshot,'external replay');
777
1013
  for(const change of changes)externalNotification(entry,change.type,
778
- path.relative(entry.root,change.path));
1014
+ path.relative(entry.root,change.path),true);
779
1015
  }catch(e){if(e.code==='ETIMEDOUT')throw e;
780
1016
  externalGap(entry,'historical_replay_failed',e);}
781
1017
  }
@@ -789,7 +1025,8 @@ async function catchUp() {
789
1025
  function respond(client,value) {
790
1026
  if(client.destroyed){event('control_reply_unavailable',{classification:'client_destroyed'});return;}
791
1027
  try{const payload=JSON.stringify(value)+'\n';
792
- client.end(payload);client.vstateReplied=true;
1028
+ mark('server.response_constructed',{bytes:payload.length});
1029
+ client.end(payload,()=>mark('server.response_delivered'));client.vstateReplied=true;
793
1030
  if(process.env.VSTATE_DEBUG_CONTROL==='1')event('control_reply_sent',{bytes:payload.length});}
794
1031
  catch(e){event('control_reply_unavailable',{classification:e.name||'serialization_error'});
795
1032
  if(!client.destroyed)client.end('{"error":"control response unavailable"}\n');}
@@ -807,10 +1044,11 @@ async function request(message) {
807
1044
  if(message.action==='sync'||message.action==='snapshot'||message.action==='prelaunch') {
808
1045
  const totalStarted=performance.now();
809
1046
  const decision=++decisionSequence;
1047
+ mark('server.decision_start',{decision,pending:pending.size,planDirty,planning,recovering,healthy,historyDisabled,decisionRequests,historyDeadlineMs,reconciliationDeadlineMs});
810
1048
  timing('daemon.decision_reason',totalStarted,{decision,action:message.action,
811
1049
  plan:process.platform==='win32'?'validate_dependencies_before_reuse':'existing_plan',
812
- probe:'reuse_only_with_identical_content_and_context',installed:'current_content_required',
813
- phase:metrics.planPhase||'initializing',pending:pending.size,
1050
+ probe:'reuse_only_with_identical_content_and_context',installed:'current_identity_required',
1051
+ planPhase:metrics.planPhase||'initializing',pending:pending.size,
814
1052
  notifications:metrics.notifications,planRebuilds:metrics.planRebuilds});
815
1053
  if(process.platform==='win32'&&process.env.VSTATE_TEST_FAULTS==='1'&&
816
1054
  process.env.VSTATE_TEST_WINDOWS_GAP_FILE&&
@@ -825,9 +1063,17 @@ async function request(message) {
825
1063
  // has been disabled: a recovered subscription may still have queued events.
826
1064
  const needsContentReconciliation=process.platform==='win32'||(hasHistory&&historyDisabled);
827
1065
  if(needsContentReconciliation&&message.action!=='prelaunch'){
828
- const started=performance.now();await refreshPlan({forceCold:true,reuse:true});
1066
+ // Windows probes inside the reconciliation, so a known-STALE sync skips it there.
1067
+ const started=performance.now();
1068
+ await refreshPlan({forceCold:true,reuse:true,skip:message.action==='sync'?knownStale:null});
829
1069
  timing('daemon.request_content_validation',started,{healthy:healthy?1:0});}
830
- if(healthy)try{const started=performance.now();await catchUp();
1070
+ // Without synchronized installed reads, the continuity barrier runs once,
1071
+ // after probing: in-process validation reads the live filesystem, and the
1072
+ // barrier then applies every change before the response is computed.
1073
+ const deferBarrier=process.platform!=='win32'&&!needsContentReconciliation&&
1074
+ message.action!=='prelaunch'&&index&&healthy&&!planning&&![...index.checks].some(([name,row])=>
1075
+ row.plan.synchronizedInstalledRead&&(!message.name||message.name===name));
1076
+ if(healthy&&!deferBarrier)try{const started=performance.now();await catchUp();
831
1077
  timing('daemon.request_catch_up',started);}catch(e){observationGap('historical_query',e);}
832
1078
  if(flushTimer||pending.size||planDirty||[...external.values()].some(e=>e.pending.size))flush();
833
1079
  let synchronizedInstalled=false;
@@ -862,10 +1108,66 @@ async function request(message) {
862
1108
  }catch(e){installedRecheck=null;observationGap('installed_recheck',e);}
863
1109
  }
864
1110
  const probeStarted=performance.now();
865
- if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch')
866
- for(const row of index.checks.values())probe(row);
1111
+ // A run's start checkpoint skips the full probe when its recording can
1112
+ // replace the certificate; the end checkpoint offers that recording.
1113
+ let adoptIntent=false,recorded;const adopting=new Set();
1114
+ if(index&&healthy&&!validatedColdIndex&&message.action!=='prelaunch'){
1115
+ const continuous=continuityBarrier(),probed=index,gaps=metrics.gaps,decided=Date.now();
1116
+ const selected=[...index.checks].filter(([name])=>!message.name||message.name===name);
1117
+ if(message.action==='snapshot'&&typeof message.adopt==='string'&&/^[0-9a-f-]{36}$/.test(message.adopt)){
1118
+ const row=index.checks.get(message.name);
1119
+ recorded=row?adopt(message.name,row,message.adopt):'recording unavailable';
1120
+ }
1121
+ adoptIntent=message.action==='snapshot'&&message.runCertificate===1&&!message.adopt&&continuous;
1122
+ const keys=new Map(selected.map(([name,row])=>[name,inputKey(row)]));
1123
+ const skip=message.action==='sync'?knownStale:()=>false;
1124
+ if(message.action==='sync')refreshReceipts();
1125
+ // With a deferred barrier, validate in-process first and launch any full
1126
+ // probe only after the barrier, so a queued edit costs one probe, not two.
1127
+ for(const [name,row] of selected)if(!skip(name,row))probe(row,continuous,{launch:!deferBarrier&&!adoptIntent});
1128
+ if(continuous&&process.env.VSTATE_TEST_FAULTS==='1'&&process.env.VSTATE_TEST_MAC_PROBE_SIGNAL){
1129
+ fs.writeFileSync(process.env.VSTATE_TEST_MAC_PROBE_SIGNAL,'validated');
1130
+ await new Promise(resolve=>setTimeout(resolve,200));
1131
+ }
1132
+ // Validation and probes block JS event delivery. Finish with a continuity
1133
+ // barrier so queued edits/gaps cannot be published using the pre-probe index.
1134
+ if(continuous||deferBarrier)try{const started=performance.now();
1135
+ await catchUp();if(planDirty||pending.size||flushTimer)flush();
1136
+ timing('daemon.request_catch_up',started,{after:'probes'});
1137
+ // Rows not yet confirmed, or whose inputs moved meanwhile, are probed
1138
+ // from the barrier, as an undeferred decision would have done.
1139
+ // Decided once, after the barrier. The start probe value is already out
1140
+ // of date; clearing it means whichever probe comes next sets the value
1141
+ // for this unchanged interval without counting as a change during the run.
1142
+ if(adoptIntent&&index===probed&&healthy&&!planning)for(const [name,row] of selected)
1143
+ if(row.probeAt<decided&&adoptable(row)){
1144
+ adopting.add(name);row.probeHash=null;
1145
+ row.adoption={token:randomUUID(),serial:inputEventSerial.get(name)||0,gaps:metrics.gaps,
1146
+ input:inputKey(row),cache:row.probeCache};
1147
+ }
1148
+ const rest=index===probed&&healthy&&!planning?selected.filter(([name,row])=>!skip(name,row)&&
1149
+ !adopting.has(name)&&(row.probeAt<decided||metrics.gaps!==gaps||inputKey(row)!==keys.get(name))):[];
1150
+ const launched=metrics.fullProbes||0;
1151
+ for(const [,row] of rest)probe(row,continuityBarrier());
1152
+ if(continuous&&(metrics.fullProbes||0)>launched){await catchUp();if(planDirty||pending.size||flushTimer)flush();}
1153
+ }catch(e){observationGap('post_probe_history',e);}
1154
+ }
867
1155
  timing('daemon.request_probes',probeStarted,{checks:index?.checks.size||0});
868
1156
  lastObservation=Date.now();publish();
1157
+ // A run checkpoint never holds an installed identity from the racy window:
1158
+ // a same-size write in the same timestamp tick would keep it. Wait the
1159
+ // window out (at most 2 s), record those files again, and mark the
1160
+ // checkpoint unusable if one is still inside it.
1161
+ let racy=false;
1162
+ if(message.action==='snapshot'||message.action==='prelaunch'){
1163
+ const names=message.name?[message.name]:null;
1164
+ const indexes=()=>[index,...[...external.values()].map(e=>e.index)].filter(Boolean);
1165
+ const wait=Math.min(racyWindowMs,Math.max(0,...indexes().map(i=>i.racyUntil(names)))-Date.now());
1166
+ if(wait>0){metrics.racyWaits=(metrics.racyWaits||0)+1;await new Promise(resolve=>setTimeout(resolve,wait+5));}
1167
+ for(const i of indexes())i.settleRacy();
1168
+ racy=indexes().some(i=>i.racyUntil(names)>Date.now());
1169
+ if(racy)metrics.racyCheckpoints=(metrics.racyCheckpoints||0)+1;
1170
+ }
869
1171
  const applicabilityStarted=performance.now();
870
1172
  const data=status(message.contextHashes,synchronizedInstalled,
871
1173
  process.platform==='win32'&&healthy&&!planning&&!planDirty);
@@ -875,8 +1177,43 @@ async function request(message) {
875
1177
  data.snapshots[name]={...combinedSummary(name),files:combinedFiles(name),
876
1178
  planGeneration:row.plan.id,
877
1179
  inputEventSerial:inputEventSerial.get(name)||0,
878
- probeHash:row.probeHash,
879
- observationHealthy:healthy&&!planning&&!recovering&&applicableExternal(name).every(e=>e.healthy)};
1180
+ // Complete only when the oldest kept event follows the given serial.
1181
+ ...(Number.isSafeInteger(message.sinceSerial)?(()=>{const list=inputEventPaths.get(name)||[],
1182
+ since=message.sinceSerial,later=list.filter(([serial])=>serial>since);
1183
+ return {inputEventPaths:later.map(([,rel])=>rel),
1184
+ inputEventPathsComplete:(inputEventSerial.get(name)||0)-since===later.length};})():{}),
1185
+ probeHash:row.probeHash,inputKey:inputKey(row),observationGaps:metrics.gaps,
1186
+ observationHealthy:healthy&&!planning&&!recovering&&!racy&&applicableExternal(name).every(e=>e.healthy)};
1187
+ // Export only a currently guarded, check-specific discovery certificate.
1188
+ // The caller compares it with the answers its own compile received. Schema 2
1189
+ // certifies the realpath of every directory readDirectory visits. The
1190
+ // snapshot stays the start of the observed interval, including validation.
1191
+ if(message.runCertificate===1&&continuityBarrier()){
1192
+ const row=index?.checks.get(message.name),cache=row?.probeCache;
1193
+ // Also after an accepted recording: the caller compares its record with
1194
+ // this certificate and finds its run facts with it.
1195
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&
1196
+ !row.plan.unresolved.length&&cache?.schema===1&&cache.input===inputKey(row)&&
1197
+ cache.hash===row.probeHash&&cache.guard===planGuard(index.bundle)&&
1198
+ cache.output&&cache.queryData&&data.snapshots[message.name])
1199
+ data.snapshots[message.name].discoveryCertificate={schema:2,planId:row.plan.id,
1200
+ cwd:row.plan.cwd,probeDefinition:sha(JSON.stringify(row.plan.probes)),
1201
+ context:cache.context,output:cache.output,stderrHash:cache.stderrHash,
1202
+ queryData:cache.queryData};
1203
+ }
1204
+ // For a run checked by full probes: the last probe's questions, only to
1205
+ // find the run's lookups outside them. Never evidence for reuse.
1206
+ if(message.runFacts===1&&data.snapshots[message.name]){
1207
+ const row=index?.checks.get(message.name),cache=row?.probeCache;
1208
+ if(row?.plan.qualification==='npm-typescript-direct-v1'&&cache?.queryData&&
1209
+ typeof cache.context==='string'&&cache.input===inputKey(row)&&cache.hash===row.probeHash&&
1210
+ cache.guard===planGuard(index.bundle))
1211
+ data.snapshots[message.name].probeQuestions={context:cache.context,queryData:cache.queryData};
1212
+ }
1213
+ const row=index?.checks.get(message.name);
1214
+ if(adopting.has(message.name)&&row?.adoption&&data.snapshots[message.name])
1215
+ data.snapshots[message.name].recordedCertificate=row.adoption.token;
1216
+ if(typeof recorded==='string'&&data.snapshots[message.name])data.snapshots[message.name].recordingIssue=recorded;
880
1217
  }
881
1218
  timing('daemon.request_applicability',applicabilityStarted,{checks:data.checks.length});
882
1219
  timing('daemon.request_total',totalStarted,{action:message.action,
@@ -900,7 +1237,7 @@ async function shutdown() {
900
1237
  healthy=true;retainIndex();healthy=false;}
901
1238
  } catch(e){event('index_retention_unavailable',{classification:e.name||'Error'});}
902
1239
  }
903
- planningWorker?.terminate().catch(()=>{});
1240
+ closeHistory();planningWorker?.terminate().catch(()=>{});
904
1241
  clearInterval(heartbeat);clearInterval(probeTimer);clearTimeout(recoveryTimer);
905
1242
  watcher?.unsubscribe().catch(()=>{});server.close();
906
1243
  for(const entry of external.values()){
@@ -918,15 +1255,19 @@ const server=net.createServer(client=>{
918
1255
  event('control_reply_unavailable',{classification:handled?'closed_without_reply':
919
1256
  'closed_before_request',request_bytes:text.length});});
920
1257
  client.on('data',d=>{
921
- if(handled)return;text+=d;if(text.length>4096){event('control_request_rejected',
1258
+ // A sync request carries each check's context digests, about 1.5 KB per
1259
+ // check; 1 MiB leaves room for hundreds of checks.
1260
+ if(handled)return;text+=d;if(text.length>1024*1024){event('control_request_rejected',
922
1261
  {classification:'request_too_large',request_bytes:text.length});client.destroy();return;}
923
1262
  if(!text.includes('\n'))return;handled=true;
924
1263
  try{const message=JSON.parse(text);
925
1264
  const decision=message.action==='sync'||message.action==='snapshot';
926
1265
  if(decision)decisionRequests++;
927
- Promise.resolve(request(message)).then(v=>respond(client,v))
928
- .catch(e=>respond(client,{error:e.message}))
929
- .finally(()=>{if(decision)decisionRequests--;});}
1266
+ traceScope({request:message.diagnostic?.request||traceId(),work:'request'},()=>{
1267
+ mark('server.request_received',{activeDecisions:decisionRequests});
1268
+ Promise.resolve(request(message)).then(v=>respond(client,v))
1269
+ .catch(e=>respond(client,{error:e.message}))
1270
+ .finally(()=>{mark('server.request_complete');if(decision)decisionRequests--;});});}
930
1271
  catch(e){respond(client,{error:e.message});}
931
1272
  });
932
1273
  });
@@ -986,15 +1327,23 @@ const heartbeat=setInterval(()=>{
986
1327
  }
987
1328
  publish();
988
1329
  },1000);
989
- const probeTimer=setInterval(async()=>{
1330
+ // Idle revalidation keeps probe results fresh for cached status without
1331
+ // launching a process: certificates are replayed in-process, and one that no
1332
+ // longer holds expires so the next decision runs the full probe. macOS history
1333
+ // replay would fork, so cached status there relies on live notifications.
1334
+ const probeTimer=setInterval(()=>traceScope({request:traceId(),work:'periodic'},async()=>{
1335
+ mark('server.periodic_start',{pending:pending.size,planDirty,planning,decisionRequests});
990
1336
  // Windows cached reads are conservatively UNVERIFIED until a full decision
991
- // reconciliation. Periodic synchronous probes cannot strengthen that cache,
992
- // but can block control requests behind several seconds of child work.
1337
+ // reconciliation, so idle revalidation cannot strengthen them.
993
1338
  if(process.platform==='win32')return;
994
- if(healthy&&index&&!planning&&!decisionRequests){
995
- try{await catchUp();for(const row of index.checks.values())probe(row);publish();}
996
- catch(e){observationGap('periodic_query',e);}
997
- }},5000);
1339
+ if(healthy&&index&&!planning&&!recovering&&!decisionRequests){
1340
+ try{if(!hasHistory)await catchUp();}catch(e){observationGap('periodic_query',e);}
1341
+ if(healthy&&index&&!planning&&!recovering)
1342
+ for(const row of index.checks.values())probe(row,true,{launch:false});
1343
+ if(planDirty)flush();else publish();
1344
+ }
1345
+ mark('server.periodic_complete');
1346
+ }),5000);
998
1347
  process.on('uncaughtExceptionMonitor',error=>{
999
1348
  try{event('observer_uncaught_exception',{pid:process.pid,code:error.code||null,
1000
1349
  message:error.message,stack:error.stack});}catch{}