@deksden-com/dd-flow-cli 0.9.0-beta.7 → 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 (121) hide show
  1. package/CHANGELOG.md +396 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +470 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  26. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  27. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  28. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  29. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  30. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  31. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  32. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  33. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  34. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  35. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  36. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  37. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  38. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  39. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  40. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  41. package/dist/runtime/context.js +3 -1
  42. package/dist/schemas/agent-profile.schema.json +1 -1
  43. package/dist/schemas/code-review-result.schema.json +2 -2
  44. package/dist/schemas/code-work-batch.schema.json +4 -3
  45. package/dist/schemas/code-work-result.schema.json +4 -4
  46. package/dist/schemas/harness-config.schema.json +23 -0
  47. package/dist/schemas/plan-review-result.schema.json +2 -2
  48. package/dist/schemas/run-control-receipt.schema.json +99 -0
  49. package/dist/schemas/run-control-request.schema.json +36 -0
  50. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  51. package/dist/services/cleanup.js +8 -5
  52. package/dist/services/cli-operation-classifier.js +11 -1
  53. package/dist/services/code-checks.js +543 -96
  54. package/dist/services/controller-fanout.js +120 -0
  55. package/dist/services/dashboard.js +9 -0
  56. package/dist/services/engines.js +50 -19
  57. package/dist/services/eval-snapshots.js +639 -60
  58. package/dist/services/execution-policy.js +177 -0
  59. package/dist/services/external-work-launch.js +110 -0
  60. package/dist/services/harness-adapter.js +147 -24
  61. package/dist/services/harness-config.js +68 -0
  62. package/dist/services/hooks.js +413 -168
  63. package/dist/services/lanes.js +1 -1
  64. package/dist/services/lifecycle-command.js +74 -6
  65. package/dist/services/lifecycle-invocations.js +637 -0
  66. package/dist/services/managed-daemon-binding.js +38 -0
  67. package/dist/services/managed-processes.js +138 -22
  68. package/dist/services/merge-queue.js +6 -6
  69. package/dist/services/merge-server.js +75 -29
  70. package/dist/services/migrations.js +1 -1
  71. package/dist/services/native-daemon-history.js +49 -0
  72. package/dist/services/native-session-control.js +39 -0
  73. package/dist/services/projects.js +7 -3
  74. package/dist/services/prompts.js +4 -2
  75. package/dist/services/protocols.js +11 -2
  76. package/dist/services/recovery-observation-budget.js +61 -0
  77. package/dist/services/recovery-snapshot-database.js +107 -0
  78. package/dist/services/run-control-receipt.js +56 -0
  79. package/dist/services/run-control-worker.js +363 -0
  80. package/dist/services/run-control.js +876 -0
  81. package/dist/services/run-controller-adapter.js +211 -0
  82. package/dist/services/run-controller-capture.js +134 -0
  83. package/dist/services/run-controller-process.js +189 -0
  84. package/dist/services/run-controller-recovery.js +222 -0
  85. package/dist/services/run-controller-state.js +31 -0
  86. package/dist/services/run-controller.js +733 -0
  87. package/dist/services/run-engine-bindings.js +20 -62
  88. package/dist/services/run-fork.js +124 -0
  89. package/dist/services/run-observations.js +112 -0
  90. package/dist/services/run-recovery-runtime.js +69 -0
  91. package/dist/services/run-recovery.js +327 -0
  92. package/dist/services/runs.js +280 -40
  93. package/dist/services/runtime-budget.js +362 -0
  94. package/dist/services/runtime-scope-capture.js +50 -0
  95. package/dist/services/runtime-scope-control.js +450 -0
  96. package/dist/services/runtime-scope-resume.js +543 -0
  97. package/dist/services/runtime-scope-stop.js +99 -0
  98. package/dist/services/runtime-scope-worker.js +210 -0
  99. package/dist/services/runtime-service.js +96 -0
  100. package/dist/services/schema-validation.js +12 -12
  101. package/dist/services/stage-context.js +2 -2
  102. package/dist/services/stage-lifecycle.js +32 -10
  103. package/dist/services/stage-pause.js +25 -13
  104. package/dist/services/usage.js +37 -93
  105. package/dist/services/vnext-code-review.js +149 -89
  106. package/dist/services/vnext-code.js +223 -215
  107. package/dist/services/vnext-execution-profile.js +5 -3
  108. package/dist/services/vnext-fanout.js +135 -21
  109. package/dist/services/vnext-merge.js +258 -89
  110. package/dist/services/vnext-plan-review.js +75 -64
  111. package/dist/services/vnext-plan.js +77 -29
  112. package/dist/services/vnext-protocolize.js +21 -14
  113. package/dist/services/vnext-specify.js +26 -16
  114. package/dist/services/work-registry.js +592 -154
  115. package/dist/services/workspace-bootstrap.js +76 -0
  116. package/dist/shared/errors.js +12 -0
  117. package/dist/storage/database.js +341 -31
  118. package/dist/storage/writer-contract.js +77 -0
  119. package/dist/storage/writer-migration.js +102 -0
  120. package/package.json +20 -13
  121. package/tools/repair-paused-run-status.mjs +59 -0
@@ -2,6 +2,8 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { quote } from "shell-quote";
6
+ import { lifecycleRetryCommands, managedLifecycleCommand } from "./lifecycle-invocations.js";
5
7
  import { AppError } from "../shared/errors.js";
6
8
  import { resolveProjectRoot } from "../storage/paths.js";
7
9
  import { findRecentMatchingHookEvent, claimStageResumeHookEvent, stageResumeMatchKey } from "./hooks.js";
@@ -25,7 +27,7 @@ export function pauseStageForUser(context, input) {
25
27
  fs.mkdirSync(pauseRoot, { recursive: true });
26
28
  fs.writeFileSync(questionPath, question);
27
29
  const now = context.now();
28
- context.db.exec("BEGIN IMMEDIATE");
30
+ context.db.beginWriteTransaction();
29
31
  try {
30
32
  const paused = context.db.run("UPDATE works SET status = 'paused', updated_at = ? WHERE work_id = ? AND status = 'running'", [now, work.work_id]);
31
33
  if (paused.changes !== 1)
@@ -71,16 +73,22 @@ export function resumeStageAfterUser(context, input) {
71
73
  errorCode: "trusted_stage_resume_required",
72
74
  operation: "stage resume"
73
75
  }).eventKey;
74
- const answerPath = path.join(path.dirname(stage.pause.question_path), "answer.md");
75
- fs.writeFileSync(answerPath, input.answer);
76
76
  const now = context.now();
77
77
  const workSession = context.db.get("SELECT id, session_id, prompt_path, result_path, status FROM work_sessions WHERE work_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1", [work.work_id]);
78
78
  if (!workSession)
79
79
  throw new AppError("runtime_missing", "Paused Work has no open Session link", 1, { work_id: work.work_id });
80
- context.db.exec("BEGIN IMMEDIATE");
80
+ const answerPath = path.join(path.dirname(stage.pause.question_path), `answer-${crypto.createHash("sha256").update(`${stage.pause.id}\0${input.answer}`).digest("hex")}.md`);
81
+ context.db.beginWriteTransaction();
81
82
  let identity;
82
83
  try {
83
84
  identity = claimStageResumeHookEvent(context, { projectId: project.id, runId: run.id, stage: stage.stage, workId: work.work_id, projectRoot, eventKey: hookEventId });
85
+ try {
86
+ fs.writeFileSync(answerPath, input.answer, { flag: "wx", mode: 0o600 });
87
+ }
88
+ catch (error) {
89
+ if (error.code !== "EEXIST" || fs.readFileSync(answerPath, "utf8") !== input.answer)
90
+ throw error;
91
+ }
84
92
  if (workSession.session_id !== identity.sessionId) {
85
93
  bindSessionForResume(context, work.work_id, identity, now);
86
94
  context.db.run("UPDATE work_sessions SET status = 'completed', updated_at = ?, completed_at = ? WHERE id = ?", [now, now, workSession.id]);
@@ -99,6 +107,7 @@ export function resumeStageAfterUser(context, input) {
99
107
  refreshRunWorkProjection(context, project.id, run.id);
100
108
  appendFlowRunTimelineEvent(context, project.id, run.id, { type: "stage_resume_session_bound", stage: stage.stage, work_id: work.work_id, session_id: identity.sessionId });
101
109
  const prompt = fs.readFileSync(workSession.prompt_path, "utf8");
110
+ const retryCommands = lifecycleRetryCommands(context, stage.stage);
102
111
  const continuation = [
103
112
  prompt.trimEnd(),
104
113
  "",
@@ -107,6 +116,7 @@ export function resumeStageAfterUser(context, input) {
107
116
  `- Question source: ${stage.pause.question_path}`,
108
117
  `- Answer source: ${answerPath}`,
109
118
  "- Continue the same stage, Work and attempt. Do not call stage start or repeat completed preparation.",
119
+ ...(retryCommands.length ? ["- A prior no-effect lifecycle rejection now has these authoritative replacement commands; use only these replacements, never invent an invocation id:", ...retryCommands.map(command => ` ${command}`)] : []),
110
120
  "",
111
121
  "<user_question>",
112
122
  fs.readFileSync(stage.pause.question_path, "utf8"),
@@ -121,7 +131,7 @@ export function resumeStageAfterUser(context, input) {
121
131
  return { ok: true, outcome: "resumed", run_id: run.id, work_id: work.work_id, stage: stage.stage, pause_id: stage.pause.id, question_path: stage.pause.question_path, answer_path: answerPath, prompt_path: workSession.prompt_path, worker_prompt_markdown: continuation, next_action: `continue_${stage.stage}` };
122
132
  }
123
133
  export function stagePauseCommand(context, input) {
124
- return `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
134
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage pause ${input.runId} --stage ${input.stage} --work ${input.workId} --question-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
125
135
  }
126
136
  /**
127
137
  * The only shell form allowed for an agent-owned HITL pause. Supplying a
@@ -147,9 +157,11 @@ Effect on scope or acceptance:
147
157
  USER_QUESTION`;
148
158
  }
149
159
  function stageResumeCommand(context, input) {
150
- return `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`;
160
+ return managedLifecycleCommand(context, `${flowCommand(context)} stage resume ${input.runId} --stage ${input.stage} --work ${input.workId} --answer-stdin --project-root ${JSON.stringify(input.projectRoot)} --json`);
151
161
  }
152
162
  export function flowCommand(context) {
163
+ const scope = context.env.DD_FLOW_INVOCATION_SCOPE;
164
+ const prefix = scope ? `DD_FLOW_INVOCATION_SCOPE=${quote([scope])} ` : "";
153
165
  const defaultHome = path.resolve(path.join(os.homedir(), ".dd-flow"));
154
166
  // Desktop/ACP tool processes are not required to inherit the environment of
155
167
  // the adapter that created their Session. In an isolated runtime, a bare
@@ -158,16 +170,16 @@ export function flowCommand(context) {
158
170
  // make every generated lifecycle command self-contained.
159
171
  const executable = context.env.DD_FLOW_BIN;
160
172
  if (executable && path.isAbsolute(executable)) {
161
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
173
+ return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(executable)}`;
162
174
  }
163
175
  const isolatedExecutable = path.join(context.ddFlowHome, "bin", "dd-flow");
164
176
  if (context.ddFlowHome !== defaultHome && fs.existsSync(isolatedExecutable)) {
165
- return `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
177
+ return `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} ${JSON.stringify(isolatedExecutable)}`;
166
178
  }
167
- return context.ddFlowHome === defaultHome ? "dd-flow" : `DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
179
+ return context.ddFlowHome === defaultHome ? `${prefix}dd-flow` : `${prefix}DD_FLOW_HOME=${JSON.stringify(context.ddFlowHome)} dd-flow`;
168
180
  }
169
181
  function requireRun(context, projectId, id) {
170
- const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_home_path, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
182
+ const rows = context.db.all("SELECT id, short_id, project_id, project_root, run_root, index_json FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [projectId, id, id]);
171
183
  if (rows.length !== 1)
172
184
  throw new AppError(rows.length ? "ambiguous_alias" : "not_found", rows.length ? "RUN alias is ambiguous" : "RUN is not registered", 1, { run_id: id });
173
185
  return rows[0];
@@ -186,9 +198,9 @@ function requireStage(run, stage, status) {
186
198
  return found;
187
199
  }
188
200
  function requireRunHome(run) {
189
- if (!run.run_home_path)
190
- throw new AppError("runtime_missing", "RUN has no artifact home", 1, { run_id: run.id });
191
- return run.run_home_path;
201
+ if (!run.run_root)
202
+ throw new AppError("runtime_missing", "RUN has no artifact root", 1, { run_id: run.id });
203
+ return run.run_root;
192
204
  }
193
205
  function nextPauseId(runHome) {
194
206
  const root = path.join(runHome, "intake", "hitl");
@@ -4,6 +4,8 @@ import { parseJsonObject } from "../shared/json.js";
4
4
  import { resolveProjectRoot } from "../storage/paths.js";
5
5
  import { registerProject, requireProjectByRoot } from "./projects.js";
6
6
  import { publicSessionIdentity } from "./session-identity.js";
7
+ import { runObservations } from "./run-observations.js";
8
+ import { ToolObservations, codexToolObservation } from "../harness-runtime/lib/tool-observations.mjs";
7
9
  export function ingestZcodeUsage(context, input) {
8
10
  return ingestHarnessUsage(context, input, { harness: "zcode-acp", label: "ZCode", idPrefix: "ZUSG", sourceKind: "zcode_session_usage_v1" });
9
11
  }
@@ -19,6 +21,14 @@ export function ingestOpenCodeUsage(context, input) {
19
21
  export function ingestAgyUsage(context, input) {
20
22
  return ingestHarnessUsage(context, input, { harness: "antigravity-cli", label: "Antigravity", idPrefix: "AUSG", sourceKind: "antigravity_cli_session_usage_v1" });
21
23
  }
24
+ /** Droid counters must describe one physical Session; inclusive roots double-count children. */
25
+ export function ingestDroidUsage(context, input) {
26
+ const payload = parseJsonObject(input.stdin || "{}", "Droid usage snapshot");
27
+ if (payload.usage_scope !== "physical_session" || !["complete", "partial"].includes(String(payload.completeness))) {
28
+ throw new Error("Droid usage requires physical_session scope and complete or partial completeness");
29
+ }
30
+ return ingestHarnessUsage(context, input, { harness: "droid-cli", label: "Droid", idPrefix: "DUSG", sourceKind: "droid_session_usage_v1" });
31
+ }
22
32
  function ingestHarnessUsage(context, input, harness) {
23
33
  const payload = parseJsonObject(input.stdin || "{}", `${harness.label} usage snapshot`);
24
34
  const usage = object(payload.usage);
@@ -157,7 +167,7 @@ export function recalculateRunUsage(context, input) {
157
167
  const usageFinal = final && unsettledSessions.length === 0;
158
168
  const persistRunProjection = !input.sessionId && !input.stage;
159
169
  if (persistRunProjection) {
160
- context.db.exec("BEGIN IMMEDIATE");
170
+ context.db.exec("SAVEPOINT run_usage");
161
171
  try {
162
172
  context.db.run("DELETE FROM usage WHERE project_id = ? AND run_id = ?", [input.projectId, input.runId]);
163
173
  for (const row of rows) {
@@ -177,13 +187,16 @@ export function recalculateRunUsage(context, input) {
177
187
  AND status = 'idle'
178
188
  AND NOT EXISTS (SELECT 1 FROM work_sessions WHERE session_id = ? AND status = 'running')`, [observedAt, session.completed_at, input.projectId, input.runId, session.session_id, session.session_id]);
179
189
  }
180
- context.db.exec("COMMIT");
190
+ context.db.exec("RELEASE run_usage");
181
191
  }
182
192
  catch (error) {
183
- context.db.exec("ROLLBACK");
193
+ context.db.exec("ROLLBACK TO run_usage");
194
+ context.db.exec("RELEASE run_usage");
184
195
  throw error;
185
196
  }
186
197
  }
198
+ const observations = persistRunProjection ? runObservations(context, input.projectId, input.runId) : null;
199
+ const legacyTools = toolCallsForLinks(context, links, accountingAt);
187
200
  return {
188
201
  ok: true,
189
202
  schema_id: "dd-flow/run-usage@2",
@@ -198,7 +211,8 @@ export function recalculateRunUsage(context, input) {
198
211
  stopped: publicSessionIdentities(context, input.projectId, settledSessions.map((session) => session.session_id)),
199
212
  unsettled: publicSessionIdentities(context, input.projectId, unsettledSessions)
200
213
  },
201
- tool_calls: toolCallsForLinks(context, links, accountingAt),
214
+ tool_calls: observations?.tools.observed_sessions ? observations.tools : legacyTools,
215
+ ...(observations ? { observations, legacy_tool_calls: legacyTools } : {}),
202
216
  coverage: rows.reduce((coverage, row) => ({ ...coverage, [row.status]: (coverage[row.status] ?? 0) + 1 }), {})
203
217
  };
204
218
  }
@@ -241,6 +255,7 @@ function publicSessionIdentities(context, projectId, storageIds) {
241
255
  }
242
256
  function toolCallsForLinks(context, links, observedAt) {
243
257
  const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
258
+ let incomplete = false;
244
259
  const sessions = new Map();
245
260
  for (const link of links)
246
261
  sessions.set(link.session_id, [...(sessions.get(link.session_id) ?? []), link]);
@@ -253,18 +268,20 @@ function toolCallsForLinks(context, links, observedAt) {
253
268
  result.unavailable_sessions.push(sessionId);
254
269
  continue;
255
270
  }
271
+ if ("completeness" in parsed && parsed.completeness !== "complete")
272
+ incomplete = true;
256
273
  result.observed_sessions += 1;
257
274
  result.total += parsed.total;
258
275
  result.failures += parsed.failures;
259
276
  for (const [name, count] of Object.entries(parsed.by_tool))
260
277
  result.by_tool[name] = (result.by_tool[name] ?? 0) + count;
261
278
  }
262
- result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length ? "partial" : "measured";
279
+ result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length || incomplete ? "partial" : "measured";
263
280
  return result;
264
281
  }
265
- function isExternalHarness(harness) { return harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
282
+ function isExternalHarness(harness) { return harness === "droid-cli" || harness === "zcode-acp" || harness === "grok-acp" || harness === "opencode-server" || harness === "antigravity-cli"; }
266
283
  function harnessSourceKind(harness) {
267
- return harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
284
+ return harness === "droid-cli" ? "droid_session_usage_v1" : harness === "grok-acp" ? "grok_session_usage_v1" : harness === "zcode-acp" ? "zcode_session_usage_v1" : harness === "antigravity-cli" ? "antigravity_cli_session_usage_v1" : "opencode_session_usage_v1";
268
285
  }
269
286
  function readHarnessToolCallsWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
270
287
  if (!providerSessionId)
@@ -302,9 +319,7 @@ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, obs
302
319
  return null;
303
320
  try {
304
321
  let sessionId;
305
- let total = 0;
306
- let failures = 0;
307
- const byTool = {};
322
+ const tools = new ToolObservations();
308
323
  for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
309
324
  if (!line.trim())
310
325
  continue;
@@ -313,6 +328,7 @@ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, obs
313
328
  event = JSON.parse(line);
314
329
  }
315
330
  catch {
331
+ tools.gaps.add("journal_invalid_record");
316
332
  continue;
317
333
  }
318
334
  const payload = object(event.payload);
@@ -321,20 +337,13 @@ function readToolCallsForWindows(transcriptPath, expectedSessionId, windows, obs
321
337
  if (event.type !== "response_item" || !payload)
322
338
  continue;
323
339
  const eventAt = stringValue(event.timestamp);
324
- if (eventAt && !windows.some((window) => eventInsideWindow(eventAt, window, observedAt)))
340
+ if (eventAt && windows.length && !windows.some((window) => eventInsideWindow(eventAt, window, observedAt)))
325
341
  continue;
326
- const type = stringValue(payload.type);
327
- if (type === "function_call" || type === "custom_tool_call") {
328
- const name = stringValue(payload.name) ?? "unknown";
329
- byTool[name] = (byTool[name] ?? 0) + 1;
330
- total += 1;
331
- }
332
- if ((type === "function_call_output" || type === "custom_tool_call_output") && outputIndicatesFailure(payload))
333
- failures += 1;
342
+ tools.add(codexToolObservation(event, expectedSessionId));
334
343
  }
335
344
  if (sessionId && sessionId !== expectedSessionId)
336
345
  return null;
337
- return { total, failures, by_tool: Object.fromEntries(Object.entries(byTool).sort(([left], [right]) => left.localeCompare(right))) };
346
+ return tools.summary();
338
347
  }
339
348
  catch {
340
349
  return null;
@@ -346,37 +355,6 @@ function eventInsideWindow(eventAt, window, observedAt) {
346
355
  const end = Date.parse(window.completed_at ?? observedAt);
347
356
  return Number.isFinite(at) && (!Number.isFinite(start) || at >= start) && (!Number.isFinite(end) || at <= end);
348
357
  }
349
- function outputIndicatesFailure(payload) {
350
- if (payload.error === true || typeof payload.error === "string")
351
- return true;
352
- return containsFailure(payload.output);
353
- }
354
- function containsFailure(value) {
355
- if (Array.isArray(value))
356
- return value.some(containsFailure);
357
- if (typeof value === "string") {
358
- if (/(?:^|\n)exit=(?:[1-9]\d*)\b|Process exited with code [1-9]\d*/.test(value))
359
- return true;
360
- const trimmed = value.trim();
361
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
362
- try {
363
- return containsFailure(JSON.parse(trimmed));
364
- }
365
- catch {
366
- return false;
367
- }
368
- }
369
- return false;
370
- }
371
- const item = object(value);
372
- if (!item)
373
- return false;
374
- if (item.ok === false || item.is_error === true || item.isError === true || item.error === true || typeof item.error === "string")
375
- return true;
376
- if (typeof item.exit_code === "number" && item.exit_code !== 0)
377
- return true;
378
- return Object.values(item).some(containsFailure);
379
- }
380
358
  /** RUN accounting belongs to the physical provider Session, not to every Work link. */
381
359
  function usageRowsForPhysicalSessions(context, links, observedAt, stage) {
382
360
  const sessions = new Map();
@@ -425,7 +403,7 @@ function hasInclusiveUsageSnapshot(context, projectId, harness, providerSessionI
425
403
  function readHarnessUsageWindow(context, harness, projectId, providerSessionId, startedAt, endedAt, observedAt) {
426
404
  if (!providerSessionId)
427
405
  return { status: "not_observable", diagnostic: "provider_session_id_missing" };
428
- const snapshots = context.db.all(`SELECT observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
406
+ const snapshots = context.db.all(`SELECT completeness, observed_at, total_tokens, input_tokens, cache_read_input_tokens, cache_write_input_tokens, output_tokens, reasoning_output_tokens
429
407
  FROM harness_usage_snapshots
430
408
  WHERE project_id = ? AND harness = ? AND provider_session_id = ? AND observed_at <= ?
431
409
  ORDER BY observed_at, id`, [projectId, harness, providerSessionId, observedAt]);
@@ -434,6 +412,8 @@ function readHarnessUsageWindow(context, harness, projectId, providerSessionId,
434
412
  const before = snapshots.filter((snapshot) => Date.parse(snapshot.observed_at) <= Date.parse(startedAt)).at(-1);
435
413
  if (!last || !before || last === before)
436
414
  return { status: "not_yet_emitted", diagnostic: `${harness}_usage_window_incomplete` };
415
+ if (harness === "droid-cli" && (last.completeness !== "complete" || before.completeness !== "complete"))
416
+ return { status: "not_observable", diagnostic: "droid-cli_usage_window_partial" };
437
417
  const delta = (value, baseline) => value === null || baseline === null ? null : Math.max(0, value - baseline);
438
418
  const input = delta(last.input_tokens, before.input_tokens);
439
419
  const cacheRead = delta(last.cache_read_input_tokens, before.cache_read_input_tokens);
@@ -575,62 +555,26 @@ function usageReport(context, input, refresh) {
575
555
  }
576
556
  function toolCallsForSessions(sessions) {
577
557
  const result = { status: "unavailable", total: 0, failures: 0, by_tool: {}, observed_sessions: 0, unavailable_sessions: [] };
558
+ let incomplete = false;
578
559
  for (const session of sessions) {
579
560
  const parsed = readToolCalls(session.transcript_path, nativeSessionId(session));
580
561
  if (!parsed) {
581
562
  result.unavailable_sessions.push(session.session_id);
582
563
  continue;
583
564
  }
565
+ if (parsed.completeness !== "complete")
566
+ incomplete = true;
584
567
  result.observed_sessions += 1;
585
568
  result.total += parsed.total;
586
569
  result.failures += parsed.failures;
587
570
  for (const [name, count] of Object.entries(parsed.by_tool))
588
571
  result.by_tool[name] = (result.by_tool[name] ?? 0) + count;
589
572
  }
590
- result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length === 0 ? "measured" : "partial";
573
+ result.status = result.observed_sessions === 0 ? "unavailable" : result.unavailable_sessions.length || incomplete ? "partial" : "measured";
591
574
  return result;
592
575
  }
593
576
  function readToolCalls(transcriptPath, expectedSessionId) {
594
- if (!transcriptPath || !fs.existsSync(transcriptPath))
595
- return null;
596
- try {
597
- let sessionId;
598
- const byTool = {};
599
- let total = 0;
600
- let failures = 0;
601
- for (const line of fs.readFileSync(transcriptPath, "utf8").split(/\r?\n/)) {
602
- if (!line.trim())
603
- continue;
604
- let event;
605
- try {
606
- event = JSON.parse(line);
607
- }
608
- catch {
609
- continue;
610
- }
611
- const payload = object(event.payload);
612
- if (!payload)
613
- continue;
614
- if (event.type === "session_meta")
615
- sessionId = stringValue(payload.id) ?? sessionId;
616
- if (event.type !== "response_item")
617
- continue;
618
- const type = stringValue(payload.type);
619
- if (type === "function_call" || type === "custom_tool_call") {
620
- const name = stringValue(payload.name) ?? "unknown";
621
- byTool[name] = (byTool[name] ?? 0) + 1;
622
- total += 1;
623
- }
624
- if ((type === "function_call_output" || type === "custom_tool_call_output") && outputIndicatesFailure(payload))
625
- failures += 1;
626
- }
627
- if (sessionId && sessionId !== expectedSessionId)
628
- return null;
629
- return { total, failures, by_tool: Object.fromEntries(Object.entries(byTool).sort(([left], [right]) => left.localeCompare(right))) };
630
- }
631
- catch {
632
- return null;
633
- }
577
+ return readToolCallsForWindows(transcriptPath, expectedSessionId, [], new Date().toISOString());
634
578
  }
635
579
  function segmentUsage(context, session, segment) {
636
580
  if (!session)