@deksden-com/dd-flow-cli 0.9.0-beta.139 → 0.9.0-beta.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.140
4
+
5
+ - Compare observable birth after EPERM so a foreign reused PID is not mistaken for the retained owner; unknown identity remains conservative.
6
+
7
+ - Separate pre-reboot physical retirement of controllers and waiting probes from their retained logical outcome.
8
+
9
+ - Preserve legacy resource writer contracts during terminal retirement and recognize retained resource aliases without changing their historical bytes.
10
+ - Retire structurally validated registries predating writer contracts without installing a new schema or fence definition.
11
+
12
+ ### Patch Changes
13
+
14
+ - Retire validated pre-reboot legacy daemon processes without claiming native Session success or signalling reused PIDs.
15
+
3
16
  ## 0.9.0-beta.139
4
17
 
5
18
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.139",
4
- "cli_commit": "bf550ff8ffdc8ce96dac783d0cfb0000a3b83e74",
5
- "built_at": "2026-10-17T07:23:43.465Z",
3
+ "cli_version": "0.9.0-beta.140",
4
+ "cli_commit": "995243962934773092d9d2a127b0e1224baa622d",
5
+ "built_at": "2026-10-17T10:10:08.823Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.2",
8
8
  "commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
@@ -232,6 +232,8 @@ async function runCliRequest(args, io, env) {
232
232
  // Registration may bootstrap the resource registry, but never the unrelated
233
233
  // RUN store. Its caller's absolute deadline also bounds that first open.
234
234
  const maintenanceOptions = isRuntimeMaintenance(output.args) ? { resourceDatabaseMode: output.args[2] === "register" ? "initialize" : output.args[2] === "status" ? "read_existing" : "prepared_existing", ...(env.DD_FLOW_HOOK_DEADLINE_AT ? { databaseDeadlineAt: Number(env.DD_FLOW_HOOK_DEADLINE_AT) } : {}) } : {};
235
+ if (output.args[0] === "runtime" && output.args[1] === "scope" && ["fence", "stop"].includes(output.args[2] ?? ""))
236
+ maintenanceOptions.resourceDatabaseMode = "retirement";
235
237
  // Keep ordinary CLI JSON/stderr compatibility; bounded maintenance callers
236
238
  // provide this request's deadline and consume its structured phase evidence.
237
239
  if (maintenanceOptions.databaseDeadlineAt !== undefined)
@@ -11,7 +11,7 @@ export function createRouterContext(env, deadlineAt) {
11
11
  }
12
12
  export function createContext(env, mode = "initialize", importPermitId, options = {}) {
13
13
  const ddFlowHome = resolveDdFlowHome(env);
14
- if (options.resourceDatabaseMode === "prepared_existing" || options.resourceDatabaseMode === "initialize" || options.resourceDatabaseMode === "create_new")
14
+ if (options.resourceDatabaseMode === "prepared_existing" || options.resourceDatabaseMode === "initialize" || options.resourceDatabaseMode === "create_new" || options.resourceDatabaseMode === "retirement")
15
15
  assertImportAdmission(ddFlowHome, importPermitId);
16
16
  return {
17
17
  ddFlowHome,
@@ -20,6 +20,19 @@ const defaultLeaseMs = 15 * 60_000;
20
20
  export function resourceHome(context) {
21
21
  return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
22
22
  }
23
+ /** Retained owners may name the same registry through a historical alias. */
24
+ export function retainedResourceHomeMatches(context, retained) {
25
+ if (retained === resourceHome(context))
26
+ return true;
27
+ if (typeof retained !== "string" || !path.isAbsolute(retained))
28
+ return false;
29
+ try {
30
+ return fs.realpathSync(retained) === fs.realpathSync(resourceHome(context));
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
23
36
  /** Runtime IDs are home-local; a shared host registry needs this extra scope. */
24
37
  export function managedProcessRuntimeHome(record, authority) {
25
38
  const metadata = parseMetadata(record.metadata_json);
@@ -300,7 +313,13 @@ export function processIsAlive(record) {
300
313
  process.kill(record.pid, 0);
301
314
  }
302
315
  catch (error) {
303
- return error.code !== "ESRCH";
316
+ const code = error.code;
317
+ if (code === "ESRCH")
318
+ return false;
319
+ // A reused PID may now belong to another user. EPERM proves presence,
320
+ // not identity; still compare its observed birth before calling it ours.
321
+ if (code !== "EPERM")
322
+ return true;
304
323
  }
305
324
  const metadata = record.metadata_json ? parseMetadata(record.metadata_json) : {};
306
325
  if (Object.hasOwn(metadata, "process_birth")) {
@@ -319,7 +338,11 @@ export function managedProcessObservation(record) {
319
338
  process.kill(record.pid, 0);
320
339
  }
321
340
  catch (error) {
322
- return error.code === 'ESRCH' ? 'absent' : 'unknown';
341
+ const code = error.code;
342
+ if (code === "ESRCH")
343
+ return "absent";
344
+ if (code !== "EPERM")
345
+ return "unknown";
323
346
  }
324
347
  const reply = spawnSync('ps', ['-o', 'lstart=,stat=', '-p', String(record.pid)], { encoding: 'utf8', timeout: 1000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' } });
325
348
  if (reply.status !== 0 || !reply.stdout.trim())
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome } from "./managed-processes.js";
5
+ import { managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome, retainedResourceHomeMatches } from "./managed-processes.js";
6
6
  import { retainedDaemonOperations } from "./run-control.js";
7
7
  import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
8
8
  /** A replaced daemon is a settled resource, never another Session to resume. */
@@ -26,7 +26,7 @@ export function historicalDaemonSettlement(context, scopeId, record) {
26
26
  if (matches.length !== 1)
27
27
  throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no unique retained receipt", 1);
28
28
  const { file, bytes, state } = matches[0], owner = state.config?.runtime_owner;
29
- if (!state.daemon_id || path.basename(file) !== `${state.daemon_id}.json` || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.lease_token !== record.lease_token || state.pid !== record.pid)
29
+ if (!state.daemon_id || path.basename(file) !== `${state.daemon_id}.json` || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.dd_flow_home !== metadata.dd_flow_home || !retainedResourceHomeMatches(context, owner.resource_home) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.lease_token !== record.lease_token || state.pid !== record.pid)
30
30
  throw new AppError("runtime_scope_owner_unknown", "Archived daemon does not match its registration", 1);
31
31
  const closed = state.shutdown_state === "clean" && state.active_tree === false
32
32
  ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
@@ -1,10 +1,11 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import os from "node:os";
4
5
  import { createContext } from "../runtime/context.js";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { runHarnessAdapter } from "./harness-adapter.js";
7
- import { managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, stopManagedProcess } from "./managed-processes.js";
8
+ import { managedLeaseReleased, managedProcessBinding, managedProcessRuntimeHome, managedProcessStatus, processIsAlive, processTreeIsAlive, resourceHome, retainedResourceHomeMatches, stopManagedProcess } from "./managed-processes.js";
8
9
  import { terminalCheckSettled } from "./code-checks.js";
9
10
  import { fenceRuntimeBudget, runtimeBudgetStatus, scopeLocalProcessRole } from "./runtime-budget.js";
10
11
  import { retainedDaemonOperations } from "./run-control.js";
@@ -26,7 +27,7 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
26
27
  const read = () => JSON.parse(fs.readFileSync(file, "utf8"));
27
28
  const state = read();
28
29
  const owner = state.config?.runtime_owner;
29
- if (!state.daemon_id || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.id !== record.id || state.resource_process.lease_token !== record.lease_token || state.pid !== record.pid)
30
+ if (!state.daemon_id || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || !retainedResourceHomeMatches(context, owner.resource_home) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.id !== record.id || state.resource_process.lease_token !== record.lease_token || state.pid !== record.pid)
30
31
  throw new AppError("runtime_scope_owner_unknown", "Native owner does not match its registration", 1);
31
32
  const closed = state.shutdown_state === "clean" && state.active_tree === false && !processTreeIsAlive(record)
32
33
  ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
@@ -38,7 +39,7 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
38
39
  throw new AppError("runtime_scope_owner_changed", "Native registration changed before control", 1);
39
40
  if (!prior && !processIsAlive(current))
40
41
  throw new AppError("runtime_scope_outcome_unknown", "Dead native owner requires retained shutdown evidence", 1);
41
- const env = { ...context.env, DD_FLOW_HOME: owner.dd_flow_home, DD_FLOW_BIN: owner.dd_flow_bin, DD_FLOW_RESOURCE_HOME: owner.resource_home };
42
+ const env = { ...context.env, DD_FLOW_HOME: owner.dd_flow_home, DD_FLOW_BIN: owner.dd_flow_bin, DD_FLOW_RESOURCE_HOME: resourceHome(context) };
42
43
  delete env.DD_FLOW_CONTROL_TOKEN;
43
44
  delete env.DD_FLOW_CONTROLLER_TOKEN;
44
45
  const receipt = await runHarnessAdapter({ executable: metadata.adapter_executable,
@@ -109,6 +110,10 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
109
110
  nodes.push({ process_id: record.id, state_dir: metadata.state_dir ?? "", settled: true, operation_id: operationId, owner_operation_id: operationId });
110
111
  continue;
111
112
  }
113
+ if (scopeLocalProcessRole(record) && previousBoot(record)) {
114
+ nodes.push({ process_id: record.id, settled: false, native_settled: false, physical_stopped: true, reason: "runtime_scope_outcome_unknown", message: "Physical owner predates host boot; logical outcome is retained" });
115
+ continue;
116
+ }
112
117
  if (scopeLocalProcessRole(record) && record.operation_id && record.pid && record.pid_started_at) {
113
118
  try {
114
119
  await stopManagedProcess(context, { id: record.id, leaseToken: record.lease_token, force: false, graceMs: 500, reason: `scope-stop:${scopeId}` });
@@ -128,6 +133,8 @@ export async function stopRuntimeScope(context, scopeId, requestId) {
128
133
  const retired = nodes.find(node => node.physical_stopped === true && node.state_dir === metadata.state_dir && node.processes?.some(item => item.process_id === record.id && JSON.stringify(item.binding) === binding));
129
134
  if (retired && !processTreeIsAlive(record))
130
135
  nodes.push({ process_id: record.id, ...(metadata.state_dir ? { state_dir: metadata.state_dir } : {}), settled: false, native_settled: false, physical_stopped: true, ...(retired.reason ? { reason: retired.reason } : {}) });
136
+ else if (previousBoot(record))
137
+ nodes.push({ process_id: record.id, settled: false, native_settled: false, physical_stopped: true, reason: "runtime_scope_outcome_unknown", message: "Physical owner predates host boot; logical outcome is retained" });
131
138
  else
132
139
  nodes.push({ process_id: record.id, settled: false, reason: "runtime_scope_resource_unsettled", message: "Resource needs its own settlement proof" });
133
140
  }
@@ -152,7 +159,7 @@ async function stopVerifiedNativeProcesses(context, scopeId, daemon) {
152
159
  if (!owner || !current || current.lease_token !== daemon.lease_token || current.pid !== daemon.pid || current.pid_started_at !== daemon.pid_started_at
153
160
  || !daemon.pid || !daemon.pid_started_at || owner.budget?.scope_id !== scopeId || owner.owner_id !== daemon.owner_id
154
161
  || owner.state_dir !== metadata.state_dir || owner.role !== metadata.role || owner.adapter_executable !== metadata.adapter_executable
155
- || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context)
162
+ || owner.dd_flow_home !== metadata.dd_flow_home || !retainedResourceHomeMatches(context, owner.resource_home)
156
163
  || state.resource_process?.id !== daemon.id || state.resource_process.lease_token !== daemon.lease_token
157
164
  || (state.pid !== daemon.pid && !(state.pid == null && state.shutdown_state === "starting")))
158
165
  return { stopped: null };
@@ -166,6 +173,12 @@ async function stopVerifiedNativeProcesses(context, scopeId, daemon) {
166
173
  && child.dispatch_barrier === "run-generation@1" && child.budget?.scope_id === scopeId;
167
174
  }) : [];
168
175
  const provider = providers.length === 1 ? providers[0] : unpublished.length === 1 ? unpublished[0] : undefined;
176
+ if (provider && previousBoot(daemon) && previousBoot(provider))
177
+ return { stopped: true, provider };
178
+ // Legacy daemons did not register their provider. A host reboot proves local
179
+ // process exit, not native Session completion. Never signal a reused PID.
180
+ if (providers.length === 0 && unpublished.length === 0 && !state.provider_process && !state.provider_pid && previousBoot(daemon))
181
+ return { stopped: true };
169
182
  if (providers.length > 1 || unpublished.length > 1 || state.provider_pid && !provider || !provider && state.shutdown_state !== "starting")
170
183
  return { stopped: null };
171
184
  try {
@@ -178,3 +191,20 @@ async function stopVerifiedNativeProcesses(context, scopeId, daemon) {
178
191
  return { stopped: false, ...(provider ? { provider } : {}), error: { code: cleanup.code ?? "physical_stop_failed", message: String(cleanup) } };
179
192
  }
180
193
  }
194
+ function previousBoot(record) {
195
+ const metadata = JSON.parse(record.metadata_json);
196
+ // A waiting, unconfirmed launch cannot outlive the pre-boot owner that alone
197
+ // could release it. This is physical retirement, not a successful launch.
198
+ const identity = record.pid ? record : record.state === "starting" && Number.isSafeInteger(metadata.owner_pid) && metadata.owner_pid > 0 && metadata.owner_pid_started_at
199
+ ? { pid: metadata.owner_pid, pid_started_at: metadata.owner_pid_started_at, metadata_json: JSON.stringify(metadata.owner_birth ? { process_birth: metadata.owner_birth } : {}) } : null;
200
+ if (!identity || processIsAlive(identity))
201
+ return false;
202
+ // Legacy lstart has no timezone. Keep a conservative 26-hour ambiguity
203
+ // margin; recent/malformed identities must use ordinary ownership proof.
204
+ const started = Date.parse(record.started_at);
205
+ const birth = identity.pid_started_at && /^\w{3} \w{3}\s+\d{1,2} \d{2}:\d{2}:\d{2} \d{4}$/.test(identity.pid_started_at)
206
+ ? Date.parse(`${identity.pid_started_at} UTC`) : NaN;
207
+ const uptime = os.uptime();
208
+ const boundary = Date.now() - uptime * 1000 - 26 * 60 * 60 * 1000;
209
+ return Number.isFinite(uptime) && uptime > 0 && Number.isFinite(started) && Number.isFinite(birth) && started < boundary && birth < boundary;
210
+ }
@@ -531,6 +531,8 @@ export function getResourceDatabase(resourceHome, mode = "initialize", deadlineA
531
531
  const deadline = requestDeadline(deadlineAt);
532
532
  deadline?.("resource_open");
533
533
  const dbPath = path.join(resourceHome, "runtime.sqlite");
534
+ if (mode === "retirement" && !fs.existsSync(dbPath))
535
+ return getResourceDatabase(resourceHome, "initialize", deadlineAt);
534
536
  const cached = resourceDatabases.get(dbPath);
535
537
  if (mode === "create_new") {
536
538
  if (cached)
@@ -562,29 +564,38 @@ export function getResourceDatabase(resourceHome, mode = "initialize", deadlineA
562
564
  if (mode === "prepared_existing" && !fs.existsSync(dbPath))
563
565
  throw new AppError("resource_storage_unprepared", "Prepare the resource registry before maintenance", 1);
564
566
  if (cached) {
565
- if (mode === "prepared_existing")
567
+ if (mode === "prepared_existing" || mode === "retirement")
566
568
  assertResourceDatabaseIdentity(cached);
567
569
  const bounded = deadlineAt === undefined ? cached : cached.withDeadline(deadlineAt);
568
- if (mode === "prepared_existing")
569
- assertResourceStoreReady({ prepare: sql => ({ get: (...params) => bounded.get(sql, params), all: (...params) => bounded.all(sql, params), run: (...params) => bounded.run(sql, params) }) });
570
+ const reader = { prepare: (sql) => ({ get: (...params) => bounded.get(sql, params), all: (...params) => bounded.all(sql, params), run: (...params) => bounded.run(sql, params) }) };
571
+ if (mode === "prepared_existing" || mode === "retirement")
572
+ assertResourceStoreReady(reader, mode === "retirement");
573
+ else {
574
+ const required = readStoreWriterContract(reader);
575
+ if (required !== null && required !== STORE_WRITER_VERSION)
576
+ throw new AppError("store_writer_incompatible", "Retirement writer cannot admit productive resource operations", 1, { required, supported: STORE_WRITER_VERSION });
577
+ }
570
578
  return bounded;
571
579
  }
572
- if (mode !== "prepared_existing")
580
+ if (mode !== "prepared_existing" && mode !== "retirement")
573
581
  ensureDir(resourceHome);
574
582
  channel("dd-flow.sqlite.ingress").publish({ phase: "store_connection", store_kind: "resource" });
575
583
  const db = new DatabaseSync(dbPath);
576
584
  try {
577
585
  channel("dd-flow.sqlite.ingress").publish({ phase: "store_writer_registration", store_kind: "resource" });
578
586
  db.exec(`PRAGMA busy_timeout = ${deadline?.("resource_open") ?? 4000}`);
579
- registerStoreWriter(db);
587
+ if (mode === "retirement")
588
+ registerReconciliationWriter(db);
589
+ else
590
+ registerStoreWriter(db);
580
591
  }
581
592
  catch (error) {
582
593
  return closeAfterOpenFailure(db, error);
583
594
  }
584
595
  channel("dd-flow.sqlite.ingress").publish({ phase: "store_validation", store_kind: "resource" });
585
596
  try {
586
- if (mode === "prepared_existing")
587
- assertResourceStoreReady(db);
597
+ if (mode === "prepared_existing" || mode === "retirement")
598
+ assertResourceStoreReady(db, mode === "retirement");
588
599
  else
589
600
  configureDatabase(db, "initialize", deadline);
590
601
  }
@@ -600,7 +611,7 @@ export function getResourceDatabase(resourceHome, mode = "initialize", deadlineA
600
611
  return closeAfterOpenFailure(db, error);
601
612
  }
602
613
  try {
603
- if (mode !== "prepared_existing")
614
+ if (mode !== "prepared_existing" && mode !== "retirement")
604
615
  executeStoreSchema(db, `
605
616
  CREATE TABLE IF NOT EXISTS managed_processes (
606
617
  id TEXT PRIMARY KEY,
@@ -669,11 +680,11 @@ function requestDeadline(at) {
669
680
  return phase => { const remaining = at - Date.now(); if (remaining <= 0)
670
681
  throw new AppError("process_heartbeat_timeout", "Maintenance storage deadline expired", 1, { phase, deadline_at: at, effect: "unknown" }); return Math.max(1, Math.min(4000, remaining)); };
671
682
  }
672
- function assertResourceStoreReady(db) {
683
+ function assertResourceStoreReady(db, retirement = false) {
673
684
  const version = readStoreWriterContract(db);
674
- if (version === null)
685
+ if (version === null && !retirement)
675
686
  throw new AppError("resource_storage_unprepared", "Prepare the resource registry writer contract before maintenance", 1);
676
- if (version !== STORE_WRITER_VERSION)
687
+ if (version !== null && version !== STORE_WRITER_VERSION && !(retirement && version === 1))
677
688
  throw new AppError("store_writer_incompatible", "This CLI cannot maintain the resource registry's required contract", 1, { required: version, supported: STORE_WRITER_VERSION });
678
689
  for (const [table, columns] of Object.entries({ managed_processes: "id,kind,pid,pid_started_at,owner_id,lease_token,lease_expires_at,project_id,run_id,work_id,check_id,operation_id,stdout_path,stderr_path,state,started_at,updated_at,finished_at,termination_reason,metadata_json", managed_resources: "resource_kind,resource_key,owner_id,lease_token,lease_expires_at,process_id,metadata_json,created_at,updated_at" })) {
679
690
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.139",
3
+ "version": "0.9.0-beta.140",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.23.0",