@deksden-com/dd-flow-cli 0.9.0-beta.145 → 0.9.0-beta.149

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 (101) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/build-info.json +7 -7
  3. package/dist/cli/command-inputs.js +6 -2
  4. package/dist/cli/command-metadata.js +18 -2
  5. package/dist/cli/diagnostic-invocation.js +46 -0
  6. package/dist/cli/diagnostic.js +120 -0
  7. package/dist/cli/help.js +35 -0
  8. package/dist/cli/memory-flow-admission.js +4 -2
  9. package/dist/cli/memory-init.js +5 -2
  10. package/dist/cli/run-cli.js +16 -15
  11. package/dist/cli.js +6 -0
  12. package/dist/domain/diagnostic-aspects.js +103 -0
  13. package/dist/domain/diagnostic-flows.js +8 -0
  14. package/dist/harness-runtime/lib/agy-native-inspection.mjs +216 -0
  15. package/dist/harness-runtime/lib/agy-native_pb.js +117 -0
  16. package/dist/harness-runtime/lib/daemon-operations.d.mts +1 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +84 -10
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +306 -34
  19. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +27 -13
  20. package/dist/harness-runtime/lib/dd-codex.mjs +4 -0
  21. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +4 -3
  22. package/dist/harness-runtime/lib/dd-droid.mjs +70 -28
  23. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -3
  24. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +66 -20
  25. package/dist/harness-runtime/lib/dd-opencode.mjs +30 -3
  26. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +7 -6
  27. package/dist/harness-runtime/lib/driver-recovery.mjs +9 -8
  28. package/dist/harness-runtime/lib/managed-daemon.d.mts +1 -1
  29. package/dist/harness-runtime/lib/managed-daemon.mjs +11 -3
  30. package/dist/harness-runtime/lib/native-children.d.mts +8 -1
  31. package/dist/harness-runtime/lib/native-children.mjs +114 -8
  32. package/dist/harness-runtime/lib/native-request-proof.mjs +1 -1
  33. package/dist/harness-runtime/lib/observation-clock.d.mts +2 -2
  34. package/dist/harness-runtime/lib/observation-clock.mjs +7 -4
  35. package/dist/harness-runtime/lib/provider-overload-policy.d.mts +1 -1
  36. package/dist/harness-runtime/lib/provider-overload-policy.mjs +1 -1
  37. package/dist/harness-runtime/lib/tool-observations.mjs +1 -1
  38. package/dist/schemas/diagnostic-stage-result.schema.json +354 -0
  39. package/dist/schemas/diagnostic-work-result.schema.json +296 -0
  40. package/dist/schemas/execution-bundle.schema.json +7 -1
  41. package/dist/schemas/project-flow-pack-manifest.schema.json +1 -1
  42. package/dist/services/check-environment.js +21 -0
  43. package/dist/services/check-runner-evidence.js +267 -15
  44. package/dist/services/code-checks.js +23 -15
  45. package/dist/services/controller-execution.js +10 -4
  46. package/dist/services/controller-fanout.js +13 -6
  47. package/dist/services/controller-memory-worker.js +3 -2
  48. package/dist/services/dashboard.js +2 -2
  49. package/dist/services/descriptor-run-settlement.js +5 -3
  50. package/dist/services/descriptor-run-start.js +22 -3
  51. package/dist/services/descriptor-stage-coordinator.js +37 -4
  52. package/dist/services/descriptor-stage-settlement.js +2 -1
  53. package/dist/services/diagnostic-evidence.js +73 -0
  54. package/dist/services/diagnostic-report-publication.js +72 -0
  55. package/dist/services/diagnostic-report.js +67 -0
  56. package/dist/services/diagnostic-run-bundle.js +74 -0
  57. package/dist/services/diagnostic-snapshot.js +159 -0
  58. package/dist/services/diagnostic-stage-handlers.js +108 -0
  59. package/dist/services/diagnostic-stage-results.js +132 -0
  60. package/dist/services/diagnostic-start.js +84 -0
  61. package/dist/services/diagnostic-work-batch.js +34 -0
  62. package/dist/services/diagnostic-work-collection.js +97 -0
  63. package/dist/services/diagnostic-work-registration.js +101 -0
  64. package/dist/services/diagnostic-work-results.js +93 -0
  65. package/dist/services/eval-snapshots.js +85 -29
  66. package/dist/services/execution-bundles.js +19 -7
  67. package/dist/services/flow-catalog.js +6 -1
  68. package/dist/services/frozen-engine.js +37 -2
  69. package/dist/services/hybrid-contracts.js +2 -0
  70. package/dist/services/installed-flow-source.js +25 -3
  71. package/dist/services/lifecycle-invocations.js +147 -34
  72. package/dist/services/memory-init-contexts.js +16 -0
  73. package/dist/services/memory-init-controller-start.js +12 -4
  74. package/dist/services/memory-init-lint-capability.js +5 -5
  75. package/dist/services/memory-init-native-capacity.js +4 -4
  76. package/dist/services/memory-init-preflight.js +10 -16
  77. package/dist/services/memory-init-worktrunk-capability.js +3 -7
  78. package/dist/services/memory-layer.js +4 -3
  79. package/dist/services/memory-tool-probe.js +25 -0
  80. package/dist/services/memory-verification-checks.js +15 -4
  81. package/dist/services/native-daemon-history.js +2 -1
  82. package/dist/services/provider-capacity.js +43 -38
  83. package/dist/services/release-cache.js +8 -0
  84. package/dist/services/run-control.js +2 -1
  85. package/dist/services/run-controller-adapter.js +2 -1
  86. package/dist/services/run-controller-process.js +2 -1
  87. package/dist/services/run-controller-recovery.js +2 -1
  88. package/dist/services/run-controller.js +85 -15
  89. package/dist/services/run-fork.js +1 -1
  90. package/dist/services/runs.js +6 -1
  91. package/dist/services/runtime-scope-resume.js +12 -6
  92. package/dist/services/runtime-scope-stop.js +3 -2
  93. package/dist/services/runtime-service.js +2 -1
  94. package/dist/services/stage-handlers.js +16 -3
  95. package/dist/services/stage-pause.js +4 -1
  96. package/dist/services/version-status.js +1 -1
  97. package/dist/services/vnext-code.js +2 -2
  98. package/dist/services/vnext-fanout.js +31 -1
  99. package/dist/services/work-registry.js +37 -6
  100. package/dist/services/workspace-bootstrap.js +2 -1
  101. package/package.json +6 -3
@@ -0,0 +1,103 @@
1
+ /** Registered baseline coverage; applicability remains agent-owned. */
2
+ export const diagnosticAspects = {
3
+ "mb-audit": [
4
+ {
5
+ "id": "01",
6
+ "question": "Structure, indexes and reachability of active memory"
7
+ },
8
+ {
9
+ "id": "02",
10
+ "question": "Links, frontmatter and durable traceability"
11
+ },
12
+ {
13
+ "id": "03",
14
+ "question": "Source of truth, duplicates, freshness and factual contradictions"
15
+ },
16
+ {
17
+ "id": "04",
18
+ "question": "Delivery docs, epics/features, protocols and verification matrices"
19
+ },
20
+ {
21
+ "id": "05",
22
+ "question": "System boundaries, C4, ADRs and contracts"
23
+ },
24
+ {
25
+ "id": "06",
26
+ "question": "Scenarios, fixtures, evidence and runners"
27
+ },
28
+ {
29
+ "id": "07",
30
+ "question": "UI contracts and user guides"
31
+ },
32
+ {
33
+ "id": "08",
34
+ "question": "Engineering standards and code contracts"
35
+ },
36
+ {
37
+ "id": "09",
38
+ "question": "Operations, release policy and active named deferrals"
39
+ },
40
+ {
41
+ "id": "10",
42
+ "question": "Memory lifecycle, archives and durable promotion"
43
+ },
44
+ {
45
+ "id": "11",
46
+ "question": "Code-to-memory traceability"
47
+ },
48
+ {
49
+ "id": "12",
50
+ "question": "Project agent skills, prompts and execution guidance"
51
+ }
52
+ ],
53
+ "project-review": [
54
+ {
55
+ "id": "structure_navigation_review",
56
+ "question": "Can readers locate system truth and delivery evidence?"
57
+ },
58
+ {
59
+ "id": "feature_epic_layer_review",
60
+ "question": "Are durable capabilities and delivery layers coherent?"
61
+ },
62
+ {
63
+ "id": "spec_conformance_review",
64
+ "question": "Does actual project behavior conform to its normative specs?"
65
+ },
66
+ {
67
+ "id": "architecture_harmonization_review",
68
+ "question": "Are architecture and boundaries coherent top-down?"
69
+ },
70
+ {
71
+ "id": "adr_decision_review",
72
+ "question": "Are significant decisions, alternatives and consequences recorded?"
73
+ },
74
+ {
75
+ "id": "scenario_evidence_review",
76
+ "question": "Do actual scenarios, fixtures, checks and evidence cover capabilities?"
77
+ },
78
+ {
79
+ "id": "contract_traceability_review",
80
+ "question": "Are public contracts traced through implementation and tests?"
81
+ },
82
+ {
83
+ "id": "frontmatter_crosslink_review",
84
+ "question": "Are metadata and cross-links factual and current?"
85
+ },
86
+ {
87
+ "id": "engineering_standards_review",
88
+ "question": "Does actual code follow applicable engineering standards?"
89
+ },
90
+ {
91
+ "id": "operations_policy_review",
92
+ "question": "Are applicable Git, environment and delivery policies followed?"
93
+ },
94
+ {
95
+ "id": "protocol_delivery_trace_review",
96
+ "question": "Do protocols and RUNs factually establish delivery and closure?"
97
+ },
98
+ {
99
+ "id": "def_followup_review",
100
+ "question": "Are active DEFs visible, deduplicated and bound to future gates?"
101
+ }
102
+ ]
103
+ };
@@ -0,0 +1,8 @@
1
+ /** Only the accepted E5 read-only slice, not a registry of future workflows. */
2
+ export const diagnosticFlowStages = Object.freeze({
3
+ "mb-audit": ["preflight", "audit", "review", "report"],
4
+ "project-review": ["preflight", "analyze", "review", "report"]
5
+ });
6
+ export function isDiagnosticFlow(id) {
7
+ return typeof id === "string" && Object.hasOwn(diagnosticFlowStages, id);
8
+ }
@@ -0,0 +1,216 @@
1
+ import * as grpc from '@grpc/grpc-js';
2
+ import { create, toBinary, fromBinary } from '@bufbuild/protobuf';
3
+ import { WaitForConversationFullyIdleRequestSchema, WaitForConversationFullyIdleResponseSchema, GetConversationMetadataRequestSchema, GetConversationMetadataResponseSchema, ForceStopCascadeTreeRequestSchema, ForceStopCascadeTreeResponseSchema, StreamAgentStateUpdatesRequestSchema, StreamAgentStateUpdatesResponseSchema } from './agy-native_pb.js';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { readFile, lstat, realpath, open } from 'node:fs/promises';
6
+ import { createReadStream, constants } from 'node:fs';
7
+ import { createHash } from 'node:crypto';
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import { fileURLToPath } from 'node:url';
11
+ import path from 'node:path';
12
+ import { processSnapshot } from './process-snapshot.mjs';
13
+
14
+ export const AGY_NATIVE_INSPECTION_CONTRACT = 'agy-native-inspection@2';
15
+ export const AGY_NATIVE_LIVE_CASES = ['root_child_running', 'child_background_running', 'grandchild_running', 'completion_delivery', 'fully_idle', 'timer_delivery', 'daemon_excluded_from_required_execution', 'owned_daemon_retirement'];
16
+ export const AGY_NATIVE_SAFETY_CASES = ['executor_error', 'timeout', 'late_activity_cancel_restart'];
17
+ export const AGY_NATIVE_QUALIFICATION_CASES = [...AGY_NATIVE_LIVE_CASES, ...AGY_NATIVE_SAFETY_CASES];
18
+ export const AGY_NATIVE_EVIDENCE_CONTRACT = 'agy-native-inspection-evidence@2';
19
+ /** Operator-retained native observations, not a bag of manually asserted PASSes.
20
+ * This validates evidence structure/meaning, not its authenticity: the operator
21
+ * still owns capture and review, as with every local qualification receipt. */
22
+ export function validateAgyNativeEvidence(evidence, identity) {
23
+ const checks = Object.fromEntries(AGY_NATIVE_QUALIFICATION_CASES.map(name => [name, false]));
24
+ const failed = new Set();
25
+ const fencedChanges = new Set();
26
+ const liveServices = new Map(), retiredServices = [];
27
+ if (evidence?.schema_id !== AGY_NATIVE_EVIDENCE_CONTRACT || evidence.binary_sha256 !== identity.sha256 || evidence.client_contract_sha256 !== identity.client_contract_sha256 || !['self', 'empty_self'].includes(evidence.root_relation) || !Array.isArray(evidence.observations)) return { qualified: false, checks };
28
+ for (const item of evidence.observations) {
29
+ if (!item || !AGY_NATIVE_QUALIFICATION_CASES.includes(item.case)) continue;
30
+ if (AGY_NATIVE_LIVE_CASES.includes(item.case) && item.evidence_kind !== 'live' || AGY_NATIVE_SAFETY_CASES.includes(item.case) && !['live', 'deterministic'].includes(item.evidence_kind)) { failed.add(item.case); continue; }
31
+ if (item.source !== 'agy.native' || typeof item.session_id !== 'string' || !item.session_id || typeof item.locator !== 'string' || !item.locator || !Number.isFinite(Date.parse(item.observed_at))) { failed.add(item.case); continue; }
32
+ const before = item.before, after = item.after, wait = item.wait;
33
+ // Observation itself must not create a user/model turn; background model
34
+ // work may continue, hence the explicit inspection-attributed counters.
35
+ if (!before || !after || !['user_turns', 'model_requests'].every(key => Number.isSafeInteger(before[key]) && before[key] >= 0 && after[key] === before[key]) || !wait || !Number.isInteger(wait.grpc_code) || wait.grpc_code < 0 || wait.grpc_code > 16 || typeof item.idle_granted !== 'boolean') { failed.add(item.case); continue; }
36
+ const idle = wait.grpc_code === 0 && wait.timed_out === false;
37
+ const busy = wait.grpc_code === 4 || wait.grpc_code === 0 && wait.timed_out === true;
38
+ const noGrant = item.idle_granted === false;
39
+ const work = Array.isArray(item.work) ? item.work : [];
40
+ const has = (kind, status) => work.some(record => record?.kind === kind && record.status === status && typeof record.id === 'string' && record.id && record.root_session_id === item.session_id);
41
+ const prior = checks[item.case]; checks[item.case] = false;
42
+ if (item.case === 'root_child_running' && item.root_status === 'terminal' && has('child', 'running') && busy && noGrant) checks[item.case] = true;
43
+ if (item.case === 'child_background_running' && has('child', 'terminal') && has('background', 'running') && busy && noGrant) checks[item.case] = true;
44
+ if (item.case === 'grandchild_running' && item.root_status === 'terminal' && item.inventory_complete === true && has('child', 'terminal') && has('grandchild', 'running') && work.every(record => record?.kind === 'grandchild' || record?.status === 'terminal') && busy && noGrant) checks[item.case] = true;
45
+ if (item.case === 'completion_delivery' && has('completion', 'pending') && busy && noGrant) checks[item.case] = true;
46
+ if (item.case === 'timer_delivery' && has('timer', 'terminal') && has('completion', 'terminal') && item.inventory_complete === true && work.every(record => record?.status === 'terminal' && record.root_session_id === item.session_id) && item.root_status === 'terminal' && idle && item.idle_granted === true) checks[item.case] = true;
47
+ if (item.case === 'fully_idle' && item.root_status === 'terminal' && item.inventory_complete === true && work.every(record => record?.status === 'terminal' && typeof record.id === 'string' && record.id && record.root_session_id === item.session_id) && idle && item.idle_granted === true) checks[item.case] = true;
48
+ if (item.case === 'executor_error' && typeof item.executor_error === 'string' && item.executor_error && noGrant && (busy || wait.grpc_code !== 0)) checks[item.case] = true;
49
+ if (item.case === 'timeout' && busy && noGrant) checks[item.case] = true;
50
+ if (item.case === 'late_activity_cancel_restart' && ['activity', 'cancel', 'restart'].includes(item.fence?.change) && typeof item.fence.before === 'string' && item.fence.before && typeof item.fence.after === 'string' && item.fence.after && item.fence.after !== item.fence.before && idle && noGrant) { checks[item.case] = true; fencedChanges.add(item.fence.change); }
51
+ const service = work.find(record => record?.kind === 'persistent' && record.native_tool === 'run_command' && record.native_is_daemon === true && record.root_session_id === item.session_id && typeof record.id === 'string' && record.id && Number.isSafeInteger(record.process?.pid) && record.process.pid > 0 && typeof record.process.birth === 'string' && record.process.birth && record.process.owned === true);
52
+ const serviceKey = service && JSON.stringify([item.session_id, service.id, service.process.pid, service.process.birth]);
53
+ if (item.case === 'daemon_excluded_from_required_execution' && service?.status === 'running' && service.process.live === true && item.root_status === 'terminal' && item.native_result_status === 'SUCCESS' && item.inventory_complete === true && work.every(record => record === service || record?.status === 'terminal' && typeof record.id === 'string' && record.id && record.root_session_id === item.session_id) && idle && item.idle_granted === true) { checks[item.case] = true; liveServices.set(serviceKey, Date.parse(item.observed_at)); }
54
+ if (item.case === 'owned_daemon_retirement' && service?.status === 'terminal' && service.process.live === false && item.cleanup?.coverage === 'complete' && item.cleanup.provider_stopped === true && typeof item.cleanup.snapshot_locator === 'string' && item.cleanup.snapshot_locator && noGrant) { checks[item.case] = true; retiredServices.push([serviceKey, Date.parse(item.observed_at)]); }
55
+ if (!checks[item.case]) failed.add(item.case);
56
+ checks[item.case] ||= prior;
57
+ }
58
+ checks.late_activity_cancel_restart &&= fencedChanges.size === 3;
59
+ checks.owned_daemon_retirement &&= liveServices.size > 0 && [...liveServices].every(([key, started]) => retiredServices.some(([retiredKey, at]) => retiredKey === key && at >= started));
60
+ for (const name of failed) checks[name] = false;
61
+ return { qualified: Object.values(checks).every(Boolean), checks };
62
+ }
63
+ const run = promisify(execFile);
64
+ const invalid = reason => Object.assign(new Error(`AGY native inspection unavailable: ${reason}`), { code: 'agy_native_inspection_unavailable', details: { reason, capability: AGY_NATIVE_INSPECTION_CONTRACT } });
65
+ const codec = schema => ({ serialize: value => Buffer.from(toBinary(schema, create(schema, value))), deserialize: bytes => fromBinary(schema, bytes) });
66
+ const method = (name, request, response) => ({ path: `/exa.language_server_pb.LanguageServerService/${name}`, requestStream: false, responseStream: false, requestSerialize: codec(request).serialize, requestDeserialize: codec(request).deserialize, responseSerialize: codec(response).serialize, responseDeserialize: codec(response).deserialize });
67
+ export const agyNativeService = {
68
+ wait: method('WaitForConversationFullyIdle', WaitForConversationFullyIdleRequestSchema, WaitForConversationFullyIdleResponseSchema),
69
+ metadata: method('GetConversationMetadata', GetConversationMetadataRequestSchema, GetConversationMetadataResponseSchema),
70
+ stop: method('ForceStopCascadeTree', ForceStopCascadeTreeRequestSchema, ForceStopCascadeTreeResponseSchema),
71
+ state: { ...method('StreamAgentStateUpdates', StreamAgentStateUpdatesRequestSchema, StreamAgentStateUpdatesResponseSchema), responseStream: true }
72
+ };
73
+ const Client = grpc.makeGenericClientConstructor(agyNativeService, 'AgyReadOnlyInspection');
74
+
75
+ async function readRegular(file, maximum, prefix = false) {
76
+ if (!path.isAbsolute(file)) throw invalid('native_file_path_invalid');
77
+ const before = await lstat(file);
78
+ if (!before.isFile() || before.isSymbolicLink() || !prefix && before.size > maximum) throw invalid('native_file_rejected');
79
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
80
+ try {
81
+ const stat = await handle.stat();
82
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || !prefix && stat.size > maximum) throw invalid('native_file_changed');
83
+ const bytes = Buffer.alloc(prefix ? Math.min(stat.size, maximum) : maximum + 1);
84
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
85
+ const after = await lstat(file);
86
+ if (!after.isFile() || after.dev !== stat.dev || after.ino !== stat.ino || !prefix && bytesRead > maximum) throw invalid('native_file_changed');
87
+ return { bytes: bytes.subarray(0, bytesRead), stat };
88
+ } finally { await handle.close(); }
89
+ }
90
+
91
+ export async function agyBinaryIdentity(bin) {
92
+ if (typeof bin !== 'string' || !bin) throw invalid('native_binary_missing');
93
+ const candidate = path.isAbsolute(bin) ? bin : (await Promise.all((process.env.PATH ?? '').split(path.delimiter).map(async dir => {
94
+ const file = path.join(dir, bin); try { return (await lstat(file)).isFile() || (await lstat(file)).isSymbolicLink() ? file : null; } catch { return null; }
95
+ }))).find(Boolean);
96
+ if (!candidate) throw invalid('native_binary_missing');
97
+ const file = await realpath(candidate), hash = createHash('sha256');
98
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
99
+ return { file, sha256: hash.digest('hex') };
100
+ }
101
+ export async function agyInspectionPolicyIdentity() {
102
+ const manifest = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8'));
103
+ const versions = [manifest.dependencies?.['@grpc/grpc-js'], manifest.dependencies?.['@bufbuild/protobuf']];
104
+ if (versions[0] !== '1.14.6' || versions[1] !== '2.16.0') throw invalid('native_protocol_dependency_unqualified');
105
+ return createHash('sha256').update(AGY_NATIVE_INSPECTION_CONTRACT).update(JSON.stringify(versions)).update(await readFile(new URL('./agy-native_pb.js', import.meta.url))).update(await readFile(new URL('./agy-native-inspection.mjs', import.meta.url))).digest('hex');
106
+ }
107
+ export async function readAgyNativeQualification(stateDir, bin, receiptFile = null) {
108
+ const binary = await agyBinaryIdentity(bin);
109
+ const clientContract = await agyInspectionPolicyIdentity();
110
+ let receipt;
111
+ const file = receiptFile ?? path.join(stateDir, 'native-inspection-qualification.json');
112
+ if (!path.isAbsolute(file)) throw invalid('qualification_path_invalid');
113
+ try {
114
+ receipt = JSON.parse((await readRegular(file, 64 * 1024)).bytes.toString('utf8'));
115
+ }
116
+ catch (error) { if (error.code !== 'ENOENT') throw invalid('qualification_receipt_invalid'); }
117
+ let evidenceValid = false;
118
+ if (typeof receipt?.evidence_path === 'string' && path.isAbsolute(receipt.evidence_path)) {
119
+ try {
120
+ const evidence = (await readRegular(receipt.evidence_path, 1024 * 1024)).bytes;
121
+ const parsed = JSON.parse(evidence.toString('utf8'));
122
+ evidenceValid = createHash('sha256').update(evidence).digest('hex') === receipt.evidence_sha256
123
+ && parsed.root_relation === receipt.root_relation
124
+ && validateAgyNativeEvidence(parsed, { ...binary, client_contract_sha256: clientContract }).qualified;
125
+ } catch { /* No retained evidence means no qualification grant. */ }
126
+ }
127
+ const qualified = evidenceValid && receipt?.schema_id === AGY_NATIVE_INSPECTION_CONTRACT && receipt.scope === 'required_execution' && receipt.binary_sha256 === binary.sha256
128
+ && receipt.client_contract_sha256 === clientContract
129
+ && ['self', 'empty_self'].includes(receipt.root_relation)
130
+ && AGY_NATIVE_QUALIFICATION_CASES.every(name => receipt.checks?.[name] === true)
131
+ && typeof receipt.evidence_sha256 === 'string' && /^[a-f0-9]{64}$/.test(receipt.evidence_sha256);
132
+ return { ...binary, client_contract_sha256: clientContract, qualified, rootRelation: qualified ? receipt.root_relation : null };
133
+ }
134
+
135
+ /** Only the log for this physical launch may nominate an endpoint. Its live
136
+ * listener must be this provider or an OS descendant, with retained PID birth. */
137
+ export async function ownedAgyEndpoint({ logFile, providerPid, providerBirth, snapshot = processSnapshot, listeners = async port => {
138
+ const { stdout } = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpn'], { timeout: 5_000, maxBuffer: 64 * 1024 });
139
+ let pid; return stdout.trim().split('\n').flatMap(line => line.startsWith('p') ? (pid = Number(line.slice(1)), []) : line.startsWith('n') ? [{ pid, address: line.slice(1) }] : []);
140
+ } }) {
141
+ if (!path.isAbsolute(logFile ?? '') || !Number.isSafeInteger(providerPid) || !providerBirth) throw invalid('provider_binding_missing');
142
+ // Startup prefix only: later log growth is not an expiry or model progress.
143
+ const { bytes, stat } = await readRegular(logFile, 1024 * 1024, true), text = bytes.toString('utf8');
144
+ const ports = [...new Set([...text.matchAll(/Language server listening on random port at (\d+) for HTTP(?:\r?$)/gm)].map(match => Number(match[1])))];
145
+ if (ports.length !== 1 || ports[0] < 1 || ports[0] > 65535) throw invalid('native_endpoint_missing_or_ambiguous');
146
+ const records = await snapshot(), provider = records.find(record => record.pid === providerPid);
147
+ if (!provider || provider.started !== providerBirth || provider.zombie) throw invalid('provider_birth_changed');
148
+ const allowed = new Set([providerPid]); let changed;
149
+ do { changed = false; for (const record of records) if (allowed.has(record.ppid) && !allowed.has(record.pid) && !record.zombie) { allowed.add(record.pid); changed = true; } } while (changed);
150
+ const owners = await listeners(ports[0]);
151
+ if (!owners.length || owners.some(owner => !allowed.has(owner.pid) || !/^(?:127\.0\.0\.1|\[::1\]|localhost):\d+$/.test(owner.address))) throw invalid('native_endpoint_foreign_or_nonloopback');
152
+ const births = owners.map(owner => ({ pid: owner.pid, started: records.find(record => record.pid === owner.pid)?.started }));
153
+ if (births.some(owner => !owner.started)) throw invalid('listener_birth_missing');
154
+ return { target: `127.0.0.1:${ports[0]}`, provider_pid: providerPid, provider_birth: providerBirth, listeners: births, log_identity: `${stat.dev}:${stat.ino}` };
155
+ }
156
+
157
+ /** No model request: metadata read followed by the native idle barrier. The
158
+ * caller owns freshness fencing and qualification; a raw reply is not a grant. */
159
+ async function agyNativeRequest({ endpoint, sessionId, cwd, remainingMs, signal, rootRelation = null, onMetadata = null, csrfToken, rootSessionId = sessionId, parentSessionId = '' }, action = 'wait', ownedSessions = []) {
160
+ if (!/^127\.0\.0\.1:\d+$/.test(endpoint?.target ?? '') || Number(endpoint.target.split(':')[1]) < 1 || Number(endpoint.target.split(':')[1]) > 65535) throw invalid('native_endpoint_nonloopback');
161
+ if (!Number.isFinite(remainingMs) || remainingMs < 1) throw invalid('observation_budget_exhausted');
162
+ const deadline = Date.now() + remainingMs;
163
+ const client = new Client(endpoint.target, grpc.credentials.createInsecure(), { 'grpc.max_receive_message_length': 1024 * 1024, 'grpc.max_send_message_length': 64 * 1024, 'grpc.enable_retries': 0 });
164
+ let active;
165
+ const headers = new grpc.Metadata();
166
+ if (typeof csrfToken === 'string' && csrfToken) headers.set('x-codeium-csrf-token', csrfToken);
167
+ const abort = () => active?.cancel();
168
+ const unary = (name, request) => new Promise((resolve, reject) => {
169
+ if (signal?.aborted) return reject(invalid('inspection_cancelled'));
170
+ active = client[name](request, headers, { deadline }, (error, value) => error ? reject(invalid(`grpc_${error.code ?? 'unknown'}`)) : value ? resolve(value) : reject(invalid('grpc_message_missing')));
171
+ });
172
+ signal?.addEventListener('abort', abort, { once: true });
173
+ try {
174
+ const metadata = await unary('metadata', { conversationId: sessionId });
175
+ onMetadata?.(metadata);
176
+ const relation = metadata.metadata;
177
+ const rootMatches = relation?.rootConversationId === rootSessionId || rootRelation === 'empty_self' && rootSessionId === sessionId && relation?.rootConversationId === '';
178
+ const workspaces = await Promise.all((relation?.workspaceUris ?? []).map(async uri => { try { return await realpath(fileURLToPath(uri)); } catch { return null; } }));
179
+ if (!relation || relation.parentConversationId !== (action === 'state' ? parentSessionId : '') || action !== 'state' && rootSessionId !== sessionId || !rootMatches || !metadata.trajectoryId || !workspaces.includes(await realpath(cwd))) throw invalid('native_metadata_binding_unproven');
180
+ if (action === 'state') {
181
+ return await new Promise((resolve, reject) => {
182
+ if (signal?.aborted) return reject(invalid('inspection_cancelled'));
183
+ active = client.state({ conversationId: sessionId, subscriberId: randomUUID() }, headers, { deadline });
184
+ let done = false;
185
+ active.on('data', value => {
186
+ if (done) return;
187
+ const update = value.update;
188
+ // Every subscription begins with a native full snapshot. Never fold
189
+ // deltas from a previous subscription into a new incarnation.
190
+ if (!update || update.conversationId !== sessionId || update.trajectoryId !== metadata.trajectoryId) return;
191
+ const snapshot = JSON.parse(JSON.stringify(update, (key, value) => key.startsWith('$') ? undefined : value));
192
+ done = true; resolve({ ...snapshot, observed_at: new Date().toISOString(), method: agyNativeService.state.path }); active.cancel();
193
+ });
194
+ active.on('error', error => { if (!done) { done = true; reject(invalid(`grpc_${error.code ?? 'unknown'}`)); } });
195
+ active.on('end', () => { if (!done) { done = true; reject(invalid('native_state_snapshot_missing')); } });
196
+ });
197
+ }
198
+ if (action === 'stop') {
199
+ const reply = await unary('stop', { conversationId: sessionId });
200
+ const stopped = reply.stoppedConversationIds, owned = new Set([sessionId, ...ownedSessions]);
201
+ if (!Array.isArray(stopped) || !stopped.length || new Set(stopped).size !== stopped.length
202
+ || ![...owned].every(id => stopped.includes(id)) || stopped.some(id => !owned.has(id))) {
203
+ const error = invalid('native_stopped_tree_binding_unproven'); error.details.stopped_session_ids = stopped ?? null; throw error;
204
+ }
205
+ return { stopped_session_ids: stopped, trajectory_id: metadata.trajectoryId, method: agyNativeService.stop.path };
206
+ }
207
+ const reply = await unary('wait', { conversationId: sessionId, inactivityTimeoutSeconds: Math.max(1, Math.min(2147483647, Math.ceil((deadline - Date.now()) / 1000))), returnOnExecutorError: false });
208
+ return { idle: reply.timedOut === false, reason: reply.timedOut ? 'native_wait_timed_out' : 'native_wait_fully_idle', method: agyNativeService.wait.path, trajectory_id: metadata.trajectoryId };
209
+ } finally { signal?.removeEventListener('abort', abort); client.close(); }
210
+ }
211
+
212
+ export const inspectAgyNative = input => agyNativeRequest(input);
213
+ /** Read-only per-node native state; no execution/readiness grant. */
214
+ export const inspectAgyNativeState = input => agyNativeRequest(input, 'state');
215
+ /** Scoped control, not an idle/physical grant. Caller must verify retirement. */
216
+ export const stopAgyNativeTree = (input, ownedSessions = []) => agyNativeRequest(input, 'stop', ownedSessions);
@@ -0,0 +1,117 @@
1
+ // @generated by protoc-gen-es v2.16.0 with parameter "target=js"
2
+ // @generated from file agy-native.proto (package exa.language_server_pb, syntax proto3)
3
+ /* eslint-disable */
4
+
5
+ import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
6
+
7
+ /**
8
+ * Describes the file agy-native.proto.
9
+ */
10
+ export const file_agy_native = /*@__PURE__*/
11
+ fileDesc("ChBhZ3ktbmF0aXZlLnByb3RvEhZleGEubGFuZ3VhZ2Vfc2VydmVyX3BiIoQBCiNXYWl0Rm9yQ29udmVyc2F0aW9uRnVsbHlJZGxlUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSIgoaaW5hY3Rpdml0eV90aW1lb3V0X3NlY29uZHMYAiABKAUSIAoYcmV0dXJuX29uX2V4ZWN1dG9yX2Vycm9yGAQgASgIIjkKJFdhaXRGb3JDb252ZXJzYXRpb25GdWxseUlkbGVSZXNwb25zZRIRCgl0aW1lZF9vdXQYASABKAgiOQoeR2V0Q29udmVyc2F0aW9uTWV0YWRhdGFSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSJsChRDb252ZXJzYXRpb25NZXRhZGF0YRIeChZwYXJlbnRfY29udmVyc2F0aW9uX2lkGAUgASgJEhwKFHJvb3RfY29udmVyc2F0aW9uX2lkGAYgASgJEhYKDndvcmtzcGFjZV91cmlzGAcgAygJIosBCh9HZXRDb252ZXJzYXRpb25NZXRhZGF0YVJlc3BvbnNlEj4KCG1ldGFkYXRhGAEgASgLMiwuZXhhLmxhbmd1YWdlX3NlcnZlcl9wYi5Db252ZXJzYXRpb25NZXRhZGF0YRIRCgludW1fc3RlcHMYAiABKA0SFQoNdHJhamVjdG9yeV9pZBgDIAEoCSI2ChtGb3JjZVN0b3BDYXNjYWRlVHJlZVJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIkAKHEZvcmNlU3RvcENhc2NhZGVUcmVlUmVzcG9uc2USIAoYc3RvcHBlZF9jb252ZXJzYXRpb25faWRzGAEgAygJIlAKHlN0cmVhbUFnZW50U3RhdGVVcGRhdGVzUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNc3Vic2NyaWJlcl9pZBgCIAEoCSJOCgxBZ2VudE1lc3NhZ2USCgoCaWQYASABKAkSEQoJcmVjaXBpZW50GAIgASgJEg4KBnNlbmRlchgDIAEoCRIPCgdjb250ZW50GAQgASgJIokBChpQZW5kaW5nQWdlbnRNZXNzYWdlc1VwZGF0ZRIPCgdpbmRpY2VzGAEgAygNEkQKFnBlbmRpbmdfYWdlbnRfbWVzc2FnZXMYAiADKAsyJC5leGEubGFuZ3VhZ2Vfc2VydmVyX3BiLkFnZW50TWVzc2FnZRIUCgx0b3RhbF9sZW5ndGgYAyABKA0iagoMVGFza1NuYXBzaG90Eg8KB3Rhc2tfaWQYASABKAkSEQoJdG9vbF9uYW1lGAIgASgJEhIKCnN0ZXBfaW5kZXgYBiABKA0SDwoHbG9nX3VyaRgHIAEoCRIRCglpc19kYWVtb24YCCABKAgiTQoOQmFja2dyb3VuZFRhc2sSOwoNdGFza19zbmFwc2hvdBgBIAEoCzIkLmV4YS5sYW5ndWFnZV9zZXJ2ZXJfcGIuVGFza1NuYXBzaG90IoABChVCYWNrZ3JvdW5kVGFza3NVcGRhdGUSDwoHaW5kaWNlcxgBIAMoDRJAChBiYWNrZ3JvdW5kX3Rhc2tzGAIgAygLMiYuZXhhLmxhbmd1YWdlX3NlcnZlcl9wYi5CYWNrZ3JvdW5kVGFzaxIUCgx0b3RhbF9sZW5ndGgYAyABKA0i5wIKEEFnZW50U3RhdGVVcGRhdGUSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhUKDXRyYWplY3RvcnlfaWQYAiABKAkSDgoGc3RhdHVzGAMgASgFEhkKEWV4ZWN1dGFibGVfc3RhdHVzGAQgASgFEhwKFGV4ZWN1dG9yX2xvb3Bfc3RhdHVzGAUgASgFEhsKE2hhc19hY3RpdmVfY2hpbGRyZW4YDiABKAgSTgoXYmFja2dyb3VuZF90YXNrc191cGRhdGUYESABKAsyLS5leGEubGFuZ3VhZ2Vfc2VydmVyX3BiLkJhY2tncm91bmRUYXNrc1VwZGF0ZRISCgpmdWxseV9pZGxlGBIgASgIElkKHXBlbmRpbmdfYWdlbnRfbWVzc2FnZXNfdXBkYXRlGBQgASgLMjIuZXhhLmxhbmd1YWdlX3NlcnZlcl9wYi5QZW5kaW5nQWdlbnRNZXNzYWdlc1VwZGF0ZSJbCh9TdHJlYW1BZ2VudFN0YXRlVXBkYXRlc1Jlc3BvbnNlEjgKBnVwZGF0ZRgBIAEoCzIoLmV4YS5sYW5ndWFnZV9zZXJ2ZXJfcGIuQWdlbnRTdGF0ZVVwZGF0ZWIGcHJvdG8z");
12
+
13
+ /**
14
+ * Describes the message exa.language_server_pb.WaitForConversationFullyIdleRequest.
15
+ * Use `create(WaitForConversationFullyIdleRequestSchema)` to create a new message.
16
+ */
17
+ export const WaitForConversationFullyIdleRequestSchema = /*@__PURE__*/
18
+ messageDesc(file_agy_native, 0);
19
+
20
+ /**
21
+ * Describes the message exa.language_server_pb.WaitForConversationFullyIdleResponse.
22
+ * Use `create(WaitForConversationFullyIdleResponseSchema)` to create a new message.
23
+ */
24
+ export const WaitForConversationFullyIdleResponseSchema = /*@__PURE__*/
25
+ messageDesc(file_agy_native, 1);
26
+
27
+ /**
28
+ * Describes the message exa.language_server_pb.GetConversationMetadataRequest.
29
+ * Use `create(GetConversationMetadataRequestSchema)` to create a new message.
30
+ */
31
+ export const GetConversationMetadataRequestSchema = /*@__PURE__*/
32
+ messageDesc(file_agy_native, 2);
33
+
34
+ /**
35
+ * Describes the message exa.language_server_pb.ConversationMetadata.
36
+ * Use `create(ConversationMetadataSchema)` to create a new message.
37
+ */
38
+ export const ConversationMetadataSchema = /*@__PURE__*/
39
+ messageDesc(file_agy_native, 3);
40
+
41
+ /**
42
+ * Describes the message exa.language_server_pb.GetConversationMetadataResponse.
43
+ * Use `create(GetConversationMetadataResponseSchema)` to create a new message.
44
+ */
45
+ export const GetConversationMetadataResponseSchema = /*@__PURE__*/
46
+ messageDesc(file_agy_native, 4);
47
+
48
+ /**
49
+ * Describes the message exa.language_server_pb.ForceStopCascadeTreeRequest.
50
+ * Use `create(ForceStopCascadeTreeRequestSchema)` to create a new message.
51
+ */
52
+ export const ForceStopCascadeTreeRequestSchema = /*@__PURE__*/
53
+ messageDesc(file_agy_native, 5);
54
+
55
+ /**
56
+ * Describes the message exa.language_server_pb.ForceStopCascadeTreeResponse.
57
+ * Use `create(ForceStopCascadeTreeResponseSchema)` to create a new message.
58
+ */
59
+ export const ForceStopCascadeTreeResponseSchema = /*@__PURE__*/
60
+ messageDesc(file_agy_native, 6);
61
+
62
+ /**
63
+ * Describes the message exa.language_server_pb.StreamAgentStateUpdatesRequest.
64
+ * Use `create(StreamAgentStateUpdatesRequestSchema)` to create a new message.
65
+ */
66
+ export const StreamAgentStateUpdatesRequestSchema = /*@__PURE__*/
67
+ messageDesc(file_agy_native, 7);
68
+
69
+ /**
70
+ * Describes the message exa.language_server_pb.AgentMessage.
71
+ * Use `create(AgentMessageSchema)` to create a new message.
72
+ */
73
+ export const AgentMessageSchema = /*@__PURE__*/
74
+ messageDesc(file_agy_native, 8);
75
+
76
+ /**
77
+ * Describes the message exa.language_server_pb.PendingAgentMessagesUpdate.
78
+ * Use `create(PendingAgentMessagesUpdateSchema)` to create a new message.
79
+ */
80
+ export const PendingAgentMessagesUpdateSchema = /*@__PURE__*/
81
+ messageDesc(file_agy_native, 9);
82
+
83
+ /**
84
+ * Describes the message exa.language_server_pb.TaskSnapshot.
85
+ * Use `create(TaskSnapshotSchema)` to create a new message.
86
+ */
87
+ export const TaskSnapshotSchema = /*@__PURE__*/
88
+ messageDesc(file_agy_native, 10);
89
+
90
+ /**
91
+ * Describes the message exa.language_server_pb.BackgroundTask.
92
+ * Use `create(BackgroundTaskSchema)` to create a new message.
93
+ */
94
+ export const BackgroundTaskSchema = /*@__PURE__*/
95
+ messageDesc(file_agy_native, 11);
96
+
97
+ /**
98
+ * Describes the message exa.language_server_pb.BackgroundTasksUpdate.
99
+ * Use `create(BackgroundTasksUpdateSchema)` to create a new message.
100
+ */
101
+ export const BackgroundTasksUpdateSchema = /*@__PURE__*/
102
+ messageDesc(file_agy_native, 12);
103
+
104
+ /**
105
+ * Describes the message exa.language_server_pb.AgentStateUpdate.
106
+ * Use `create(AgentStateUpdateSchema)` to create a new message.
107
+ */
108
+ export const AgentStateUpdateSchema = /*@__PURE__*/
109
+ messageDesc(file_agy_native, 13);
110
+
111
+ /**
112
+ * Describes the message exa.language_server_pb.StreamAgentStateUpdatesResponse.
113
+ * Use `create(StreamAgentStateUpdatesResponseSchema)` to create a new message.
114
+ */
115
+ export const StreamAgentStateUpdatesResponseSchema = /*@__PURE__*/
116
+ messageDesc(file_agy_native, 14);
117
+
@@ -1 +1,2 @@
1
1
  export function inspectDaemonOperation(root: string, id: string): Promise<Record<string, unknown>>;
2
+ export function validateNativeLaunchEnvironment(value: unknown): Record<string, string | null>;
@@ -5,7 +5,9 @@ import { randomUUID } from "node:crypto";
5
5
  import path from "node:path";
6
6
  import { hashJson, writeJsonAtomic } from "./runner-events.mjs";
7
7
  import { errorRecord, isObservationLoss, isProvenShellCorrection, reportedError } from "./operation-errors.mjs";
8
- import { assertDaemonOwnership, budgetObservationScope, releaseBudgetObservation, completeBudgetOperation } from "./managed-daemon.mjs";
8
+ import { assertDaemonOwnership, budgetObservationScope, releaseBudgetObservation, completeBudgetOperation, stopProcessGroup } from "./managed-daemon.mjs";
9
+ import { processSnapshot } from "./process-snapshot.mjs";
10
+ import { NATIVE_SHUTDOWN_CONTRACT, NATIVE_SHUTDOWN_PHASES } from "./native-children.mjs";
9
11
  import { readModelObservations, modelAttribution } from "./model-observations.mjs";
10
12
  import { publishOperationProgress, publishOperationWaiting, withOperationProgress } from './operation-progress.mjs';
11
13
  import { prepareDaemonRequest } from "./native-request-proof.mjs";
@@ -16,6 +18,20 @@ const dispatchGenerations = new Map();
16
18
  const shutdowns = new Map();
17
19
  const settlements = new Map();
18
20
 
21
+ // Configuration locations only: credentials and arbitrary process env are not
22
+ // replay inputs. The browser cache is additive for historical five-key launches.
23
+ const nativeLaunchEnvironmentKeys = ["CODEX_HOME", "DD_FLOW_CONFIG_HOME", "DD_FLOW_HOME", "DD_FLOW_BIN", "DD_FLOW_RESOURCE_HOME"];
24
+ export function validateNativeLaunchEnvironment(value) {
25
+ const cache = value?.PLAYWRIGHT_BROWSERS_PATH;
26
+ if (!value || typeof value !== "object" || Array.isArray(value)
27
+ || Object.keys(value).some(key => ![...nativeLaunchEnvironmentKeys, "PLAYWRIGHT_BROWSERS_PATH"].includes(key))
28
+ || nativeLaunchEnvironmentKeys.some(key => value[key] !== null && typeof value[key] !== "string")
29
+ || Object.hasOwn(value, "PLAYWRIGHT_BROWSERS_PATH") && cache !== null
30
+ && (typeof cache !== "string" || !cache || cache.length > 4096 || cache.includes("\0") || !path.isAbsolute(cache)))
31
+ throw Object.assign(new Error("Native launch requires explicit configuration locations and an absolute browser cache"), { code: "runtime_scope_native_launch_required" });
32
+ return value;
33
+ }
34
+
19
35
  // Progress is not outcome authority. Once native effect is retained, a failed
20
36
  // diagnostic write must keep its identity and prohibit redispatch.
21
37
  async function publishRetainedProgress(root, id, phase, result, metadata) {
@@ -41,28 +57,84 @@ export function commitDaemonShutdown(root) {
41
57
  fence.committed = true; shutdowns.set(root, fence);
42
58
  }
43
59
 
60
+ function ownedSnapshot(snapshot, rootPid) {
61
+ const owned = new Set([rootPid]); let size;
62
+ do { size = owned.size; for (const item of snapshot) if (owned.has(item.ppid)) owned.add(item.pid); } while (size !== owned.size);
63
+ return snapshot.filter(item => item.pid !== rootPid && owned.has(item.pid) && !item.zombie);
64
+ }
65
+ const mergeProcesses = (...snapshots) => [...new Map(snapshots.flat().map(item => [`${item.pid}:${item.started}`, item])).values()];
66
+
44
67
  /** The same incarnation may retry remaining cleanup, never a native Turn. */
45
68
  export async function completeDaemonShutdown(root, state, persist, prepare, phases) {
46
69
  const daemonId = state.daemon_id ?? state.config?.daemonId;
47
70
  let shutdown = state.shutdown;
48
- if (!shutdown || shutdown.daemon_id !== daemonId) shutdown = { schema_id: "dd-flow/daemon-shutdown@1", daemon_id: daemonId, generation: dispatchGenerations.get(root) ?? 0, phases: {} };
49
- shutdown = { ...shutdown, required_phases: ["tree", ...phases.map(([phase]) => phase)] };
71
+ if (shutdown && shutdown.schema_id !== NATIVE_SHUTDOWN_CONTRACT) throw Object.assign(new Error("shutdown contract mismatch; retain historical owner evidence"), { code: "native_contract_mismatch" });
72
+ if (!shutdown || shutdown.daemon_id !== daemonId) shutdown = { schema_id: NATIVE_SHUTDOWN_CONTRACT, daemon_id: daemonId, generation: dispatchGenerations.get(root) ?? 0, phases: {} };
73
+ shutdown = { ...shutdown, required_phases: [...new Set([...NATIVE_SHUTDOWN_PHASES, ...phases.map(([phase]) => phase)])] };
50
74
  try {
51
- if (!shutdown.phases.tree) {
75
+ if (!shutdown.phases.execution) {
76
+ // Some native adapters close their provider inside prepare(). Observe
77
+ // ownership first, before that close can reparent a support process.
78
+ const before = ownedSnapshot(await processSnapshot(), state.pid);
79
+ shutdown = { ...shutdown, generation: dispatchGenerations.get(root) ?? shutdown.generation,
80
+ owned_processes: mergeProcesses(shutdown.owned_processes ?? [], before), inventory_complete: state.pid === process.pid };
81
+ await persist({ shutdown });
52
82
  const result = await prepare();
53
- shutdown = { ...shutdown, phases: { tree: true }, tree_observed_at: new Date().toISOString(), result: result ?? { clean: true } };
54
83
  commitDaemonShutdown(root);
55
- await persist({ shutdown, shutdown_state: "stopping", active_tree: false });
84
+ // Compute ancestry within each snapshot, not across recycled parent PIDs.
85
+ const resources = mergeProcesses(shutdown.owned_processes, ownedSnapshot(await processSnapshot(), state.pid));
86
+ shutdown = { ...shutdown, phases: { execution: true }, owned_processes: resources,
87
+ inventory_complete: state.pid === process.pid, tree_observed_at: new Date().toISOString(), result: result ?? {} };
88
+ await persist({ shutdown, shutdown_state: "stopping" });
56
89
  }
57
90
  commitDaemonShutdown(root);
58
91
  for (const [phase, action] of phases) {
92
+ if (phase === "daemon_resource" && !shutdown.phases.owned_resources) {
93
+ if (!shutdown.inventory_complete) throw Object.assign(new Error("owned resource coverage is unknown"), { code: "native_resources_unproven" });
94
+ for (const retained of shutdown.owned_processes ?? []) {
95
+ const current = (await processSnapshot()).find(item => item.pid === retained.pid);
96
+ if (!current || current.started !== retained.started || current.zombie) continue;
97
+ await stopProcessGroup({ pid: retained.pid, started: retained.started }, 1000, { detached: false });
98
+ }
99
+ const remaining = await processSnapshot();
100
+ if ((shutdown.owned_processes ?? []).some(retained => remaining.some(item => item.pid === retained.pid && item.started === retained.started && !item.zombie))) throw Object.assign(new Error("owned resources remain live"), { code: "native_resources_unproven" });
101
+ const nativeResources = shutdown.result?.native_resources;
102
+ const resourceGap = nativeResources?.entries?.some(resource => resource.state !== "stopped"
103
+ && !(resource.classification === "required_work" && shutdown.result?.native_settled === true && shutdown.result?.settled === true)
104
+ && !(shutdown.owned_processes ?? []).some(item => resource.pid === item.pid && resource.started === item.started));
105
+ shutdown = { ...shutdown, phases: { ...shutdown.phases, owned_resources: !resourceGap },
106
+ resource_retirement: { confirmed: !resourceGap, reason: resourceGap ? "native_resource_identity_unproven" : "owned_processes_retired" } };
107
+ await persist({ shutdown });
108
+ }
59
109
  if (shutdown.phases[phase]) continue;
60
110
  await action();
61
111
  shutdown = { ...shutdown, phases: { ...shutdown.phases, [phase]: true } };
62
112
  await persist({ shutdown });
63
113
  }
64
- const result = { ...shutdown.result, daemon_id: daemonId, stopped: true, settled: true, shutdown_contract: "dd-flow/daemon-shutdown@1", _shutdown: true };
65
- await persist({ shutdown, shutdown_state: result.clean === true ? "clean" : "unclean", active_tree: false, stopped_at: new Date().toISOString(), cleanup_error: null });
114
+ const publishedRevision = state.native_execution_revision;
115
+ const sameRevision = publishedRevision === undefined || publishedRevision === shutdown.result?.native_settlement?.revision;
116
+ const nativeSettled = sameRevision && state.active_tree === false && !state.unclaimed_activity && shutdown.result?.settled === true && shutdown.result?.native_settled === true && shutdown.result?.native_settlement?.confirmed !== false;
117
+ const physicalStopped = shutdown.required_phases.every(phase => shutdown.phases[phase] === true);
118
+ // Cleanup retries/joining controls advance the control fence, not the old
119
+ // native Turn. Publish the current control generation with retained phases.
120
+ shutdown = { ...shutdown, generation: dispatchGenerations.get(root) ?? shutdown.generation };
121
+ const { result: ignored, ...proof } = shutdown; void ignored;
122
+ let result = { ...shutdown.result, daemon_id: daemonId, stopped: true, physical_stopped: physicalStopped,
123
+ settled: nativeSettled, native_settled: nativeSettled, clean: shutdown.result?.clean !== false && nativeSettled && physicalStopped,
124
+ resource_retirement: shutdown.resource_retirement,
125
+ shutdown_contract: NATIVE_SHUTDOWN_CONTRACT, shutdown_proof: proof, _shutdown: true };
126
+ shutdown = { ...shutdown, result };
127
+ // prepare() owns execution quiescence. A stale publication must not clear
128
+ // activity that arrives while its durable write is in flight.
129
+ await persist({ shutdown, shutdown_state: result.clean === true ? "clean" : "unclean", stopped_at: new Date().toISOString(), cleanup_error: null });
130
+ if (nativeSettled && (state.native_execution_revision !== publishedRevision || state.active_tree !== false || state.unclaimed_activity)) {
131
+ shutdown = { ...shutdown, phases: { ...shutdown.phases, execution: false } };
132
+ result = { ...result, clean: false, settled: false, native_settled: false,
133
+ native_settlement: { ...result.native_settlement, confirmed: false, reason: "late_activity_during_shutdown_publication" },
134
+ shutdown_proof: { ...proof, phases: shutdown.phases } };
135
+ shutdown = { ...shutdown, result };
136
+ await persist({ shutdown, shutdown_state: "unclean" });
137
+ }
66
138
  return result;
67
139
  } catch (error) {
68
140
  if (error.code === "tree_not_settled" && !shutdowns.get(root)?.committed) throw error;
@@ -255,7 +327,9 @@ export async function durableDaemonDispatch(root, request, action, observe) {
255
327
  const control = ["daemon.stop", "session.cancel"].includes(request.operation);
256
328
  if (!productive.has(request.operation) && !control) return await action();
257
329
  const generation = dispatchGenerations.get(root) ?? 0;
258
- if (control) { dispatchGenerations.set(root, generation + 1); const state = await read(path.join(root,'daemon.json')); await writeJsonAtomic(path.join(root,'operation-fence.json'), { generation: generation + 1, daemon_id: state?.daemon_id ?? state?.config?.daemonId ?? null }); }
330
+ // Once retirement is committed there is no productive dispatch to cancel.
331
+ // Retrying/joining cleanup must not stale its own durable generation proof.
332
+ if (control && !(request.operation === "daemon.stop" && shutdowns.get(root)?.committed)) { dispatchGenerations.set(root, generation + 1); const state = await read(path.join(root,'daemon.json')); await writeJsonAtomic(path.join(root,'operation-fence.json'), { generation: generation + 1, daemon_id: state?.daemon_id ?? state?.config?.daemonId ?? null }); }
259
333
  const assertCurrent = () => { assertDaemonDispatch(root); if (generation !== (dispatchGenerations.get(root) ?? 0)) throw Object.assign(new Error("control cancelled a queued productive dispatch"), { code: "operation_cancelled" }); };
260
334
  const stop = request.operation === "daemon.stop" ? (shutdowns.get(root) ?? { pending: 0, committed: false }) : null;
261
335
  if (stop) { stop.pending++; shutdowns.set(root, stop); }
@@ -301,7 +375,7 @@ export async function durableDaemonStart(options, action) {
301
375
  // Keep only configuration locations, never inherited credentials. The owned
302
376
  // scope can reuse this exact adapter launch after retiring the old daemon.
303
377
  const launch = { schema_id: "dd-flow/native-launch@1", executable: options.entryPath ?? null, args: process.argv.slice(2), cwd: process.cwd(), runtime_owner: process.env.DD_FLOW_RUNTIME_OWNER ?? null,
304
- env: Object.fromEntries(["CODEX_HOME", "DD_FLOW_CONFIG_HOME", "DD_FLOW_HOME", "DD_FLOW_BIN", "DD_FLOW_RESOURCE_HOME"].map(key => [key, process.env[key] ?? null])) };
378
+ env: validateNativeLaunchEnvironment(Object.fromEntries([...nativeLaunchEnvironmentKeys, ...(process.env.PLAYWRIGHT_BROWSERS_PATH === undefined ? [] : ["PLAYWRIGHT_BROWSERS_PATH"])].map(key => [key, process.env[key] ?? null]))) };
305
379
  const request = prepareDaemonRequest({ id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation: "daemon.start", params: { ...options, runtimeOwner: process.env.DD_FLOW_RUNTIME_OWNER ?? null, resourceHome: process.env.DD_FLOW_RESOURCE_HOME ?? null, launch } });
306
380
  return await journalDispatch(root, request, () => action(options));
307
381
  }