@deksden-com/dd-flow-cli 0.9.0-beta.64 → 0.9.0-beta.74

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 (52) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/help.js +2 -0
  3. package/dist/cli/run-cli.js +15 -0
  4. package/dist/harness-runtime/bin/dd-agy.mjs +4 -2
  5. package/dist/harness-runtime/bin/dd-codex.mjs +4 -2
  6. package/dist/harness-runtime/bin/dd-droid.mjs +4 -2
  7. package/dist/harness-runtime/bin/dd-grok.mjs +4 -2
  8. package/dist/harness-runtime/bin/dd-opencode.mjs +4 -2
  9. package/dist/harness-runtime/bin/dd-zcode.mjs +15 -7
  10. package/dist/harness-runtime/lib/daemon-operations.mjs +108 -10
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +5 -4
  12. package/dist/harness-runtime/lib/dd-codex.mjs +38 -4
  13. package/dist/harness-runtime/lib/dd-droid.mjs +55 -20
  14. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +3 -2
  15. package/dist/harness-runtime/lib/dd-grok.mjs +14 -8
  16. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +43 -8
  17. package/dist/harness-runtime/lib/dd-opencode.mjs +5 -4
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +182 -16
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +162 -63
  20. package/dist/harness-runtime/lib/driver-recovery.mjs +2 -2
  21. package/dist/harness-runtime/lib/managed-daemon.mjs +27 -5
  22. package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
  23. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  24. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  25. package/dist/services/code-checks.js +120 -19
  26. package/dist/services/controller-fanout.js +11 -0
  27. package/dist/services/engines.js +40 -9
  28. package/dist/services/eval-snapshots.js +30 -47
  29. package/dist/services/harness-adapter.js +12 -6
  30. package/dist/services/lifecycle-invocations.js +12 -8
  31. package/dist/services/native-session-control.js +6 -0
  32. package/dist/services/projects.js +6 -2
  33. package/dist/services/recovery-observation-budget.js +9 -1
  34. package/dist/services/run-control-worker.js +19 -0
  35. package/dist/services/run-control.js +87 -13
  36. package/dist/services/run-controller-adapter.js +134 -7
  37. package/dist/services/run-controller.js +11 -3
  38. package/dist/services/run-engine-bindings.js +1 -1
  39. package/dist/services/run-fork.js +124 -0
  40. package/dist/services/run-observations.js +112 -0
  41. package/dist/services/runs.js +1 -11
  42. package/dist/services/schema-validation.js +1 -1
  43. package/dist/services/stage-lifecycle.js +14 -2
  44. package/dist/services/usage.js +20 -87
  45. package/dist/services/vnext-code-review.js +35 -27
  46. package/dist/services/vnext-code.js +62 -26
  47. package/dist/services/vnext-merge.js +8 -8
  48. package/dist/services/vnext-plan-review.js +16 -16
  49. package/dist/services/vnext-plan.js +51 -11
  50. package/dist/services/work-registry.js +48 -27
  51. package/dist/shared/errors.js +12 -0
  52. package/package.json +1 -1
@@ -23,10 +23,21 @@ import {
23
23
  const REQUEST_SCHEMA = "dd-zcode/daemon-request@1";
24
24
  const RESPONSE_SCHEMA = "dd-zcode/daemon-response@1";
25
25
  const STATE_SCHEMA = "dd-zcode/daemon-state@1";
26
- // An agent can spend several minutes in a single tool operation or provider
27
- // turn without emitting an ACP update. This is an idle deadline, not a
28
- // wall-clock cap: every observed update resets it.
26
+ // ACP silence starts diagnostics; it is not a provider-failure deadline.
29
27
  export const DEFAULT_LIVENESS_TIMEOUT_MS = 600_000;
28
+ export const DEFAULT_REQUEST_TIMEOUT_MS = null;
29
+
30
+ function positiveDuration(value, name, fallback) {
31
+ const duration = value ?? fallback;
32
+ if (!Number.isFinite(duration) || duration <= 0) throw new DaemonError("invalid_duration", `${name} must be a positive finite duration`, false, { name, value: duration });
33
+ return duration;
34
+ }
35
+
36
+ function optionalDuration(value, name, fallback) {
37
+ if (value === null) return null;
38
+ if (value === undefined && fallback === null) return null;
39
+ return positiveDuration(value, name, fallback);
40
+ }
30
41
 
31
42
  class DaemonError extends Error {
32
43
  constructor(code, message, retryable = false, details) {
@@ -114,7 +125,7 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
114
125
  }
115
126
 
116
127
  function sameConfig(state, config) {
117
- return ["cwd", "journal", "bin", "zcodePath", "ddFlowBin", "ddFlowHome", "projectRoot", "resourceHome", "livenessTimeoutMs"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null));
128
+ return ["cwd", "journal", "bin", "zcodePath", "ddFlowBin", "ddFlowHome", "projectRoot", "resourceHome", "livenessTimeoutMs", "requestTimeoutMs"].every((key) => (state.config?.[key] ?? null) === (config[key] ?? null));
118
129
  }
119
130
 
120
131
  export async function startDaemon(options) {
@@ -135,7 +146,8 @@ export async function startDaemon(options) {
135
146
  projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : null,
136
147
  resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null,
137
148
  env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
138
- livenessTimeoutMs: options.livenessTimeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS,
149
+ livenessTimeoutMs: positiveDuration(options.livenessTimeoutMs, "livenessTimeoutMs", DEFAULT_LIVENESS_TIMEOUT_MS),
150
+ requestTimeoutMs: optionalDuration(options.requestTimeoutMs, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS),
139
151
  };
140
152
  await prepareRuntimeOwner(config, paths.dir);
141
153
  await mkdir(paths.dir, { recursive: true, mode: 0o700 });
@@ -230,7 +242,96 @@ export class DaemonRuntime {
230
242
  const known = this.sessions.get(params.sessionId);
231
243
  const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
232
244
  const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
233
- return { ...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter, daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter), assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); } };
245
+ return {
246
+ ...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
247
+ daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
248
+ assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); },
249
+ // The bridge owns the quiet timer; the daemon owns the durable status.
250
+ // Do not infer provider state or cancel here.
251
+ onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs }) => {
252
+ const request = this.state.active_request;
253
+ // `sessionId` is ACP identity; the durable request's `session_id` is
254
+ // native/provider identity. These strings can coincide but are not
255
+ // interchangeable.
256
+ if (!request || request.adapter_session_id !== sessionId) return;
257
+ const adapterSessionId = request.adapter_session_id;
258
+ let diagnostics;
259
+ try {
260
+ // Both are non-mutating native observations. In particular, do not
261
+ // inspect/resume a running child: some ZCode children are known to
262
+ // be non-resident and unsubscribable while their request is alive.
263
+ const [resident, topology] = await Promise.all([
264
+ this.bridge.request("zcode/session/resident", { sessionId: adapterSessionId }, 5_000),
265
+ this.bridge.request("zcode/session/retainedSubagents", { sessionId: adapterSessionId }, 5_000),
266
+ ]);
267
+ diagnostics = {
268
+ observed_at: new Date().toISOString(), adapter_session_id: adapterSessionId,
269
+ resident: resident?.resident === true,
270
+ child_session_ids: zcodeChildSessionIds(topology?.topology ?? topology),
271
+ };
272
+ } catch (error) {
273
+ diagnostics = { observed_at: new Date().toISOString(), adapter_session_id: adapterSessionId, observation_error: errorPayload(error) };
274
+ }
275
+ // The probe is asynchronous. Do not recreate a completed or
276
+ // reconnected request with a stale callback snapshot.
277
+ const current = this.state.active_request;
278
+ if (!current || current.request_id !== request.request_id || current.adapter_session_id !== adapterSessionId || this.activeGeneration !== request.generation) return;
279
+ await this.persist({ active_request: {
280
+ ...current,
281
+ observation: { state: "quiet", request_id: requestId, detected_at: new Date().toISOString(), last_activity_at: new Date(lastActivityAt).toISOString(), quiet_threshold_ms: idleTimeoutMs, diagnostics },
282
+ } });
283
+ },
284
+ // Native create publishes its identities before post-create work. Keep
285
+ // that allocation in the same durable request record so recovery can
286
+ // distinguish "no Session allocated" from "allocated, later step lost".
287
+ onSessionAliasAllocated: async (identity) => {
288
+ const current = this.state.active_request;
289
+ await this.persist({
290
+ active_request: {
291
+ ...(current ?? {
292
+ request_id: randomUUID(),
293
+ session_id: params.sessionId ?? null,
294
+ adapter_session_id: identity.adapter_session_id,
295
+ generation: this.controlGeneration ?? 0,
296
+ dispatched_at: new Date().toISOString(),
297
+ }),
298
+ allocation: {
299
+ adapter_session_id: identity.adapter_session_id,
300
+ provider_session_id: null,
301
+ cwd: identity.cwd,
302
+ allocated_at: new Date().toISOString(),
303
+ },
304
+ },
305
+ });
306
+ },
307
+ onSessionAllocated: async (identity) => {
308
+ const current = this.state.active_request;
309
+ // A late identity is evidence, not permission to resurrect a completed
310
+ // request or overwrite another operation's ownership.
311
+ this.track(identity);
312
+ if (!current || current.allocation?.adapter_session_id !== identity.adapter_session_id) {
313
+ await this.persist({});
314
+ return;
315
+ }
316
+ await this.persist({
317
+ active_request: {
318
+ ...(current ?? {
319
+ request_id: randomUUID(),
320
+ session_id: params.sessionId ?? null,
321
+ adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
322
+ generation: this.controlGeneration ?? 0,
323
+ dispatched_at: new Date().toISOString(),
324
+ }),
325
+ allocation: {
326
+ adapter_session_id: identity.adapter_session_id,
327
+ provider_session_id: identity.provider_session_id,
328
+ cwd: identity.cwd,
329
+ allocated_at: new Date().toISOString(),
330
+ },
331
+ },
332
+ });
333
+ },
334
+ };
234
335
  }
235
336
 
236
337
  track(result, topology = result?.evidence?.subagents) {
@@ -265,19 +366,36 @@ export class DaemonRuntime {
265
366
  return evidence?.read?.projection?.status !== "idle" || !Array.isArray(children) || children.length > 0 || Boolean(evidence?.subagents?.observation_error);
266
367
  }
267
368
 
268
- async productive(name, task) {
369
+ async productive(name, task, request = null) {
269
370
  if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
270
371
  this.activeProductive = name;
271
372
  this.activeGeneration = this.controlGeneration ?? 0;
272
373
  try {
273
- await this.persist({ active_tree: true, active_operation: name });
374
+ await this.persist({ active_tree: true, active_operation: name, ...(request ? { active_request: request } : {}) });
274
375
  const result = await task();
275
376
  this.track(result);
276
377
  const running = this.treeRunning(result);
277
- await this.persist({ active_tree: running, active_operation: null });
278
- return result;
378
+ await this.persist({ active_tree: running, active_operation: null, active_request: null });
379
+ return { ...result, settled: !running };
279
380
  } catch (error) {
280
- await this.persist({ active_operation: null });
381
+ // Keep an allocated identity visible until the controller seals the
382
+ // outcome. Clearing it here was the source of `provider_identity_unknown`
383
+ // after a late/partial create: the native Session existed, but the
384
+ // daemon state no longer carried the only correlation to it.
385
+ const active = this.state?.active_request;
386
+ try {
387
+ await this.persist({
388
+ active_operation: null,
389
+ active_request: active?.allocation
390
+ ? { ...active, outcome: "unknown", error: errorPayload(error) }
391
+ : null,
392
+ });
393
+ } catch (cleanupError) {
394
+ // Preserve the operation's primary failure if durable cleanup itself
395
+ // is unavailable. The caller will retain the outcome as unknown and
396
+ // the next recovery pass can retry the state write.
397
+ if (error && typeof error === "object") error.cleanup_error = errorPayload(cleanupError);
398
+ }
281
399
  throw error;
282
400
  } finally { this.activeProductive = null; this.activeGeneration = null; }
283
401
  }
@@ -303,7 +421,7 @@ export class DaemonRuntime {
303
421
  cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
304
422
  started_at: this.state.started_at, updated_at: this.state.updated_at,
305
423
  shutdown_state: this.state.shutdown_state, recovery_status: this.state.recovery_status,
306
- active_tree: this.state.active_tree, active_operation: this.activeProductive,
424
+ active_tree: this.state.active_tree, active_operation: this.activeProductive, active_request: this.state.active_request ?? null,
307
425
  sessions: [...this.sessions.values()], config: this.state.config,
308
426
  };
309
427
  if (operation === "hook.resolve") {
@@ -312,10 +430,29 @@ export class DaemonRuntime {
312
430
  if (!session?.root_provider_session_id) throw new DaemonError("hook_identity_unknown", "ZCode hook Session is not a tracked controlled Session", false, { session_id: sessionId });
313
431
  return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} };
314
432
  }
315
- if (operation === "session.create") return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized));
433
+ if (operation === "session.create") {
434
+ const request = {
435
+ request_id: randomUUID(),
436
+ session_id: params.sessionId ?? null,
437
+ adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
438
+ generation: this.controlGeneration ?? 0,
439
+ dispatched_at: new Date().toISOString(),
440
+ request_timeout_ms: this.state.config.requestTimeoutMs,
441
+ };
442
+ return await this.productive(operation, async () => await createSessionWithBridge(this.bridge, this.options(params), this.initialized), request);
443
+ }
316
444
  if (operation === "session.prompt") {
317
445
  if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
318
- const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, this.options(params)); });
446
+ const known = this.sessions.get(params.sessionId);
447
+ const request = {
448
+ request_id: randomUUID(),
449
+ session_id: params.sessionId,
450
+ adapter_session_id: params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId,
451
+ generation: this.controlGeneration ?? 0,
452
+ dispatched_at: new Date().toISOString(),
453
+ request_timeout_ms: this.state.config.requestTimeoutMs,
454
+ };
455
+ const result = await this.productive(operation, async () => { await this.requireSettled(params); return await promptSessionWithBridge(this.bridge, { ...this.options(params), timeoutMs: request.request_timeout_ms }); }, request);
319
456
  const session = this.sessions.get(result.provider_session_id); if (session) session.completed_turns = (session.completed_turns ?? 0) + 1;
320
457
  await this.persist(); return result;
321
458
  }
@@ -327,6 +464,13 @@ export class DaemonRuntime {
327
464
  const topology = closed.topology;
328
465
  if (topology?.root_session_id !== params.sessionId || !Array.isArray(topology.child_session_ids) || topology.child_session_ids.some(id => typeof id !== "string" || !id)) return { ...closed, settled: false, observation_error: { code: "topology_incomplete", message: "closed Session has no native topology identity" } };
329
466
  const previous = this.state.settlement_observations?.[params.sessionId];
467
+ // A confirmed closed tree cannot resume through this daemon. Retain
468
+ // that terminal evidence even after its bridge has been retired.
469
+ for (const receipt of [previous, closed]) {
470
+ if (receipt?.close?.closed === true && receipt.settled === true
471
+ && receipt.topology?.complete === true && receipt.topology.root_session_id === params.sessionId
472
+ && Array.isArray(receipt.topology.child_session_ids)) return receipt;
473
+ }
330
474
  const known = [...new Set([...topology.child_session_ids, ...(previous?.topology?.child_session_ids ?? [])])];
331
475
  const observation = { ...closed, ...await observeClosedZcodeTree(this.bridge, params.sessionId, known), observed_at: new Date().toISOString() };
332
476
  // Observations are not ownership inventory: their timestamps must not
@@ -412,14 +556,36 @@ export class DaemonRuntime {
412
556
  }
413
557
  }
414
558
  if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
559
+ // A cooperative close can make the native Session non-resident while its
560
+ // original ACP prompt is still pending. For an explicitly whole-RUN
561
+ // cancellation this daemon owns the bridge process group, so retire that
562
+ // group now rather than reporting a clean-looking but still live tree.
563
+ // A per-child cancellation never takes this path.
564
+ let bridgeClosed = false;
565
+ if (this.activeProductive && cancel) {
566
+ try {
567
+ if (typeof this.bridge.close === "function") await this.bridge.close();
568
+ bridgeClosed = true;
569
+ } catch (error) {
570
+ throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false);
571
+ }
572
+ }
415
573
  const deadline = Date.now() + 5000;
416
574
  while (this.activeProductive && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
417
575
  if (this.activeProductive) throw new DaemonError("tree_not_settled", "productive operation did not settle after cancellation");
418
- await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null });
419
- return { daemon_id: this.state.daemon_id, stopped: true, clean: true, _shutdown: true };
576
+ // The owned bridge process group is the physical stop barrier. A native
577
+ // closed/resident receipt only proves API state and cannot seal recovery.
578
+ // A bridge without a close method occurs only in in-memory diagnostics: it
579
+ // owns no OS process boundary. Production AcpBridge always has one.
580
+ try { if (!bridgeClosed && typeof this.bridge.close === "function") await this.bridge.close(); }
581
+ catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false); }
582
+ await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null, active_request: null, physical_settlement: { confirmed_at: new Date().toISOString(), bridge_pid: this.bridge.child?.pid ?? null } });
583
+ return { daemon_id: this.state.daemon_id, stopped: true, clean: true, physical_settlement: this.state.physical_settlement, _shutdown: true };
420
584
  }
421
585
 
422
586
  async shutdown() {
587
+ // prepareStop has already closed the owned bridge. Signal shutdown may get
588
+ // here directly, so close remains idempotent.
423
589
  await this.bridge.close();
424
590
  await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
425
591
  const resourceProcess = this.state.resource_process;