@dtlabs/ai-toolkit 0.16.4 → 0.16.5
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 +10 -0
- package/bin/cli.js +24 -15
- package/docs/task-executor-bootstrap.md +6 -6
- package/docs/task-executor-cli.md +46 -5
- package/lib/task-executor/index.js +270 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
## 0.1.4 (2026-07-21)
|
|
2
|
+
## [0.16.5](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.4...v0.16.5) (2026-10-05)
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
* **task-executor:** implement the real executor status command ([58710aa](https://github.com/dtlab-tech/ai-toolkit/commit/58710aa5824108355076df697291a39dc8cd22f1))
|
|
8
|
+
* **task-executor:** permissions, ledger finalize, review verdicts ([91fedef](https://github.com/dtlab-tech/ai-toolkit/commit/91fedef1e3adb0b8ca64114f786d324e43991d64))
|
|
9
|
+
* **task-executor:** restore permission bypass and close ledger activities ([40aa1d5](https://github.com/dtlab-tech/ai-toolkit/commit/40aa1d5ba115a2438b6af66ff26a50956b9dcd3e))
|
|
10
|
+
* **task-executor:** surface build/test-driven review FAIL reasons ([09c0052](https://github.com/dtlab-tech/ai-toolkit/commit/09c0052109195d2b2b2f336a7b0fa1a892512787))
|
|
11
|
+
|
|
2
12
|
## [0.16.4](https://github.com/dtlab-tech/ai-toolkit/compare/v0.16.3...v0.16.4) (2026-10-02)
|
|
3
13
|
|
|
4
14
|
|
package/bin/cli.js
CHANGED
|
@@ -2671,14 +2671,18 @@ function _executorCurrentBranchRef(projectDir) {
|
|
|
2671
2671
|
// METADATA_RESERVED_FIELD, METADATA_UNKNOWN_KEY, INVALID_STATUS.
|
|
2672
2672
|
//
|
|
2673
2673
|
// 1 (unexpected I/O) — the DEFAULT for everything else, including every
|
|
2674
|
-
// *_CORRUPTED/CORRUPT_* code
|
|
2675
|
-
//
|
|
2676
|
-
//
|
|
2677
|
-
//
|
|
2678
|
-
//
|
|
2679
|
-
//
|
|
2680
|
-
//
|
|
2681
|
-
//
|
|
2674
|
+
// *_CORRUPTED/CORRUPT_* code (LEDGER_CORRUPTED/PRICING_CORRUPTED among
|
|
2675
|
+
// them — status()'s own ledger/pricing-file reads), every
|
|
2676
|
+
// LEDGER_READ_FAILED/PRICING_READ_FAILED/GIT_*_FAILED/*_FAILED
|
|
2677
|
+
// git-command-spawn code, STATE_UNRECOVERABLE, and — explicitly, per this
|
|
2678
|
+
// task's own brief — NOT_IMPLEMENTED. `diagnose` is still a
|
|
2679
|
+
// NOT_IMPLEMENTED stub in lib/task-executor/index.js (attributed to
|
|
2680
|
+
// US-06-TASK-BE-02, which has not actually built its real body yet);
|
|
2681
|
+
// `status` now has a real implementation (it never was actually assigned
|
|
2682
|
+
// a Work Breakdown task of its own — its stub's US-06-TASK-BE-01
|
|
2683
|
+
// attribution was stale, that task is resume/reconcile) and is wired
|
|
2684
|
+
// through exactly like the other commands, its errors surfacing through
|
|
2685
|
+
// this same mapping table honestly.
|
|
2682
2686
|
//
|
|
2683
2687
|
// 8 (paused awaiting human decision) has no throw site and is deliberately
|
|
2684
2688
|
// absent from this table: execute()'s/resume()'s 'paused' run status is a
|
|
@@ -2725,6 +2729,7 @@ const EXECUTOR_EXIT_CODE_BY_ERROR_CODE = {
|
|
|
2725
2729
|
// though 1 is also the default fallback below)
|
|
2726
2730
|
STATE_CORRUPTED: 1, RECEIPT_CORRUPTED: 1, INTENT_CORRUPTED: 1, LEASE_CORRUPTED: 1,
|
|
2727
2731
|
CORRUPT_LEDGER: 1, STATE_UNRECOVERABLE: 1, NOT_IMPLEMENTED: 1,
|
|
2732
|
+
LEDGER_READ_FAILED: 1, LEDGER_CORRUPTED: 1, PRICING_READ_FAILED: 1, PRICING_CORRUPTED: 1,
|
|
2728
2733
|
GIT_SPAWN_FAILED: 1, GIT_STATUS_FAILED: 1, GIT_ADD_FAILED: 1, GIT_WRITE_TREE_FAILED: 1,
|
|
2729
2734
|
GIT_DIFF_FAILED: 1, GIT_COMMIT_TREE_FAILED: 1, REF_UPDATE_FAILED: 1, GIT_CAT_FILE_FAILED: 1,
|
|
2730
2735
|
GIT_MERGE_BASE_FAILED: 1, GIT_REV_PARSE_FAILED: 1, GIT_CHERRY_PICK_FAILED: 1,
|
|
@@ -2785,13 +2790,17 @@ async function handleExecutorCommand(argv) {
|
|
|
2785
2790
|
function emitSuccess(result) {
|
|
2786
2791
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
2787
2792
|
// Tech-Spec section 10 exit code 8 ("paused awaiting human decision") is a
|
|
2788
|
-
// SUCCESS-path outcome, not an error —
|
|
2789
|
-
// result objects can legitimately carry `runStatus: 'paused'`
|
|
2790
|
-
// exit-code mapping comment above EXECUTOR_EXIT_CODE_BY_ERROR_CODE
|
|
2791
|
-
// no *_ERROR code maps to 8).
|
|
2792
|
-
//
|
|
2793
|
-
//
|
|
2794
|
-
//
|
|
2793
|
+
// SUCCESS-path outcome, not an error — execute()'s, resume()'s and now
|
|
2794
|
+
// status()'s result objects can legitimately carry `runStatus: 'paused'`
|
|
2795
|
+
// (see the exit-code mapping comment above EXECUTOR_EXIT_CODE_BY_ERROR_CODE
|
|
2796
|
+
// for why no *_ERROR code maps to 8). A `status` call truthfully reporting
|
|
2797
|
+
// a paused run surfaces the same exit 8 signal a direct execute()/resume()
|
|
2798
|
+
// call would — deliberate, not a special case: the exit code reflects the
|
|
2799
|
+
// run's actual state, not which subcommand observed it. reconcile/stop/
|
|
2800
|
+
// replan results never carry a `runStatus` field at all, and diagnose
|
|
2801
|
+
// never reaches this success path (still NOT_IMPLEMENTED) — so this check
|
|
2802
|
+
// stays a no-op for those subcommands' result shapes without needing to be
|
|
2803
|
+
// scoped by subcommand.
|
|
2795
2804
|
process.exitCode = result && result.runStatus === 'paused' ? 8 : 0;
|
|
2796
2805
|
}
|
|
2797
2806
|
|
|
@@ -57,12 +57,12 @@ conversation at all — the operator uses the read-only `status` subcommand docu
|
|
|
57
57
|
ai-toolkit executor status --project <path> --run-id <uuid>
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
`status` returns task status counts, run status, and (when measurable) total tokens/cost from
|
|
61
|
+
the run's own ledger — see `docs/task-executor-cli.md` for the exact result shape and the
|
|
62
|
+
null-compatibility rules around `totalTokens`/`totalCostUsd`. `diagnose` is still a stub that
|
|
63
|
+
always throws `NOT_IMPLEMENTED` (exit 1, attributed to `US-06-TASK-BE-02`); until it lands,
|
|
64
|
+
deeper per-attempt evidence inspection means using `reconcile`/`resume`, both fully implemented
|
|
65
|
+
and reporting each task's classification.
|
|
66
66
|
|
|
67
67
|
For the full flag reference, exit-code table, and JSON result schemas of every subcommand
|
|
68
68
|
(`start`, `status`, `diagnose`, `stop`, `reconcile`, `resume`, `replan`), see
|
|
@@ -57,6 +57,18 @@ Only `maxConcurrency=1` is implemented — any other requested value is **reject
|
|
|
57
57
|
on any platform other than `win32` (see "Platform qualification" below), before touching any
|
|
58
58
|
other validation.
|
|
59
59
|
|
|
60
|
+
**Permission bypass.** Every real implementation/review worker this loop dispatches is spawned
|
|
61
|
+
with `--permission-mode auto --permission-prompts none` appended to its CLI args
|
|
62
|
+
(`EXECUTOR_PERMISSION_BYPASS_ARGS` in `lib/task-executor/index.js`). A non-interactive `--print`
|
|
63
|
+
invocation has no TTY to answer a permission prompt, so without this every Write/Edit/mkdir tool
|
|
64
|
+
call is denied by default, regardless of task content — confirmed against a real run where every
|
|
65
|
+
BE/FE task failed verification because its expected files/directories were never created. This
|
|
66
|
+
restores exactly the invocation Tech-Spec OQ-01's pre-Gate-1 spike validated ("Percorso C") and
|
|
67
|
+
Gate 1 was approved against; it had gone missing from the actual `dispatchTaskAttempt`/`runReview`
|
|
68
|
+
implementation. The mode is deliberately unscoped within the task's own `--project` directory —
|
|
69
|
+
the executor's job is letting a developer agent write anywhere under that project, so (unlike
|
|
70
|
+
pm-phase1/2's read-only-text workers) a narrower allowlist is not an option here.
|
|
71
|
+
|
|
60
72
|
### `status`
|
|
61
73
|
|
|
62
74
|
```bash
|
|
@@ -66,11 +78,40 @@ ai-toolkit executor status --project <path> --run-id <uuid>
|
|
|
66
78
|
`--run-id` is required (usage error, exit 2, if omitted). Read-only summary of a run: task
|
|
67
79
|
status counts, current run status, and total cost from the ledger.
|
|
68
80
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
Backs `lib/task-executor/index.js`'s `status()`. Returns:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"protocolVersion": 1,
|
|
86
|
+
"runId": "...",
|
|
87
|
+
"runStatus": "running",
|
|
88
|
+
"taskCounts": { "pending": 0, "active": 0, "checkpointed": 0, "integrated": 0, "skipped": 0, "blocked": 0 },
|
|
89
|
+
"totalTokens": null,
|
|
90
|
+
"totalCostUsd": null
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`taskCounts` is derived straight from the persisted `state.json` (`store.readState`) — never
|
|
95
|
+
mutates it. `totalTokens` sums every numeric `phase_delta_tokens` entry in the run's own
|
|
96
|
+
`<runId>-token-ledger.json`; `totalCostUsd` additionally requires the target project's own
|
|
97
|
+
`docs/token-pricing.json` (same file and 80/20 input/output split `lib/workflow-artifacts.js`
|
|
98
|
+
uses for feature Token-Estimate documents) and a non-null `model` on each ledger entry to attach
|
|
99
|
+
a rate to. `dispatchTaskAttempt`/`runReview` (`lib/task-executor/index.js`) always open their
|
|
100
|
+
`implementation`/`review` ledger activities with `model: null`, so `totalCostUsd` is `null` until
|
|
101
|
+
that changes — this is a known gap, not a bug: **null means "unavailable", never a fabricated
|
|
102
|
+
zero** (same null-compatibility convention as the Token-Estimate document). `totalTokens` does
|
|
103
|
+
not share that gap: `_runTaskToResolution` now closes every `implementation`/`review` activity
|
|
104
|
+
(`done` with its measured tokens on a normal return, `failed` with `tokens: null` on a genuine
|
|
105
|
+
spawn-level error) immediately after each dispatch, so a completed or still-running real run
|
|
106
|
+
reports real summed tokens — previously these activities were opened and never closed at all, so
|
|
107
|
+
every run's `totalTokens` was unconditionally `null` regardless of outcome. If `runStatus`
|
|
108
|
+
happens to be `'paused'`, the CLI exits 8, same as `execute`/`resume` reporting that status
|
|
109
|
+
directly — the exit code reflects the run's actual state, not which subcommand observed it.
|
|
110
|
+
|
|
111
|
+
If the run is missing or its `state.json` is corrupted, `STATE_NOT_FOUND`/`STATE_CORRUPTED`
|
|
112
|
+
propagate unmodified (exit 4 / exit 1 respectively). An unreadable or unparseable ledger or
|
|
113
|
+
pricing file throws `LEDGER_READ_FAILED`/`LEDGER_CORRUPTED`/`PRICING_READ_FAILED`/
|
|
114
|
+
`PRICING_CORRUPTED` (exit 1) rather than silently reporting a partial or zeroed-out total.
|
|
74
115
|
|
|
75
116
|
### `diagnose`
|
|
76
117
|
|
|
@@ -74,6 +74,29 @@ function _buildProcessTag(runId, taskId, attemptNumber) {
|
|
|
74
74
|
// the tagged "worker" under test, per this task's own testing constraint.
|
|
75
75
|
const DEFAULT_TAGGED_PROCESS_EXE_BASENAME = 'claude.exe';
|
|
76
76
|
|
|
77
|
+
// Permission-bypass flags required for every real (non-test-fixture) worker
|
|
78
|
+
// spawn — implementation AND review alike. Without them, a non-interactive
|
|
79
|
+
// `--print` invocation has no TTY to answer a permission prompt, so every
|
|
80
|
+
// Write/Edit/mkdir tool call is denied by default: this was validated
|
|
81
|
+
// (Tech-Spec OQ-01, "Percorso C", resolved 2026-09-18, pre-Gate-1 spike) as
|
|
82
|
+
// `--permission-mode auto --permission-prompts none` — "Exit 0 confirmed
|
|
83
|
+
// with structured JSON output and full usage data" — and is NOT a new or
|
|
84
|
+
// speculative flag choice, it restores exactly what Gate 1 was approved
|
|
85
|
+
// against (see internal_docs/features/FTR-018-deterministic-task-execution-
|
|
86
|
+
// checkpoints-and-resume/history/pre-consolidation-2026-09-20-*.md). It was
|
|
87
|
+
// missing from dispatchTaskAttempt's/runReview's actual baseSpawnArgs/
|
|
88
|
+
// spawnArgs literals — every real implementation/review dispatch was
|
|
89
|
+
// therefore guaranteed to have every write denied, regardless of task
|
|
90
|
+
// content (confirmed against a real blocked run: INFRA-TASK-INFRA-01's own
|
|
91
|
+
// post-dispatch verification found none of its expected directories had
|
|
92
|
+
// been created). `--permission-mode auto` is deliberately broad (unscoped
|
|
93
|
+
// within the spawned cwd) — the executor's whole job is letting a developer
|
|
94
|
+
// agent write anywhere under the task's project, so a narrower scoped
|
|
95
|
+
// allowlist is not an option here the way it was for pm-phase1/2's
|
|
96
|
+
// read-only-text workers (see lib/workflow-control.js's own doc comment on
|
|
97
|
+
// why THAT subsystem sidesteps Write entirely instead).
|
|
98
|
+
const EXECUTOR_PERMISSION_BYPASS_ARGS = ['--permission-mode', 'auto', '--permission-prompts', 'none'];
|
|
99
|
+
|
|
77
100
|
// Real, positive-evidence-only liveness query for a tagged process, using
|
|
78
101
|
// the exact E-02-qualified Win32_Process/CIM technique proven in the Gate-1
|
|
79
102
|
// evidence spike, adapted from `Name='node.exe'` to the caller-supplied
|
|
@@ -255,6 +278,44 @@ function _err(code, message) {
|
|
|
255
278
|
return e;
|
|
256
279
|
}
|
|
257
280
|
|
|
281
|
+
// Mirrors lib/workflow-control.js's worker() token-measurement convention
|
|
282
|
+
// exactly (same four usage fields, same "only trust a whole, non-negative
|
|
283
|
+
// total" rule) so this module reports token usage under the same shared
|
|
284
|
+
// definition of "measured" vs "unavailable" the rest of the toolkit uses.
|
|
285
|
+
// Returns null (never 0) when usage is missing, partial, or computes to a
|
|
286
|
+
// non-positive total.
|
|
287
|
+
function _measureTokensFromSpawnResult(spawnResult) {
|
|
288
|
+
const usage = spawnResult && spawnResult.result && spawnResult.result.usage;
|
|
289
|
+
if (!usage) return null;
|
|
290
|
+
const counts = [
|
|
291
|
+
usage.input_tokens, usage.output_tokens,
|
|
292
|
+
usage.cache_read_input_tokens || 0, usage.cache_creation_input_tokens || 0,
|
|
293
|
+
];
|
|
294
|
+
if (!counts.every(function (n) { return Number.isInteger(n) && n >= 0; })) return null;
|
|
295
|
+
const total = counts.reduce(function (a, b) { return a + b; }, 0);
|
|
296
|
+
return Number.isSafeInteger(total) && total > 0 ? total : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Finalizes the 'implementation' or 'review' ledger activity dispatchTaskAttempt/
|
|
300
|
+
// runReview already opened for this attempt (Tech-Spec section 6 ledger-kind
|
|
301
|
+
// strings) — previously left open forever by _runTaskToResolution regardless
|
|
302
|
+
// of outcome (confirmed against a real run: every 'implementation'/'review'
|
|
303
|
+
// entry stayed status:'running' indefinitely, even for permanently-failed
|
|
304
|
+
// tasks). `kind` must be 'implementation' or 'review'; the agent key this
|
|
305
|
+
// reconstructs must match, byte for byte, the literal string
|
|
306
|
+
// dispatchTaskAttempt/runReview used when calling ledger.open (their own
|
|
307
|
+
// 'executor:'+runId+':'+task.id+':'+kind construction) — finalizeActivity
|
|
308
|
+
// requires an exact operation_id match, no name-only fallback.
|
|
309
|
+
function _finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, kind, options) {
|
|
310
|
+
return ledger.finalizeActivity(
|
|
311
|
+
store._executionPaths(executionRoot, runId).runDir,
|
|
312
|
+
runId,
|
|
313
|
+
'executor:' + runId + ':' + taskId + ':' + kind,
|
|
314
|
+
attemptNumber,
|
|
315
|
+
options
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
258
319
|
function _requireNonEmptyString(value, label) {
|
|
259
320
|
if (typeof value !== 'string' || value.length === 0) {
|
|
260
321
|
throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a non-empty string');
|
|
@@ -529,7 +590,7 @@ async function dispatchTaskAttempt(args) {
|
|
|
529
590
|
const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
|
|
530
591
|
const baseSpawnArgs = Array.isArray(args.spawnArgs)
|
|
531
592
|
? args.spawnArgs
|
|
532
|
-
: ['--print', '--output-format', 'json', '--agent', identity.nativeName];
|
|
593
|
+
: ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
|
|
533
594
|
// Append the same processIdentity tag already persisted above as one extra
|
|
534
595
|
// trailing argv element, so it appears verbatim in the spawned process's
|
|
535
596
|
// own command line — this is what makes _findLiveTaggedProcess's later OS
|
|
@@ -625,6 +686,12 @@ function _buildReviewPrompt(task, diff) {
|
|
|
625
686
|
// text> }. When the reply does not match the template (missing/malformed
|
|
626
687
|
// envelope, or free text that doesn't follow it), `passed` is reported as
|
|
627
688
|
// `null` rather than guessed — this function never fabricates a pass.
|
|
689
|
+
// `criticalFindings` captures both the "CRITICAL (blocks merge):" section AND
|
|
690
|
+
// any "Build:"/"Tests:" empirical-verification line carrying the template's
|
|
691
|
+
// own ❌ marker — the agent's contract allows FAIL via either path ("FAIL =
|
|
692
|
+
// 1+ CRITICAL findings OR build/test failure"), so a build/test-driven FAIL
|
|
693
|
+
// is reported just as explainably as a findings-driven one, never as an
|
|
694
|
+
// empty, unexplained list.
|
|
628
695
|
// Authoritative pass/fail enforcement against the Tech-Spec's binding rule
|
|
629
696
|
// ("review passed=true with no blocking finding") is intentionally left to
|
|
630
697
|
// US-04-TASK-BE-03 (closing / combined verification+review outcome
|
|
@@ -655,6 +722,26 @@ function _extractReviewVerdict(spawnResult) {
|
|
|
655
722
|
});
|
|
656
723
|
}
|
|
657
724
|
|
|
725
|
+
// The agent's own contract (src/claude/agents/gaia-review-solution.md,
|
|
726
|
+
// "## Output": "FAIL = 1+ CRITICAL findings OR build/test failure") allows
|
|
727
|
+
// a FAIL verdict with zero CRITICAL findings, driven instead by its
|
|
728
|
+
// "Build: ✅/❌ ..." / "Tests: ✅/❌ ..." empirical-verification lines.
|
|
729
|
+
// Without surfacing those too, a build/test-driven FAIL was
|
|
730
|
+
// indistinguishable from an unexplained one — criticalFindings: [] either
|
|
731
|
+
// way (confirmed against a real run: a review-only task with zero code
|
|
732
|
+
// changes failed review twice with reviewPassed:false, criticalFindings:
|
|
733
|
+
// [], and no other evidence persisted anywhere to tell the two cases
|
|
734
|
+
// apart). Any Build/Tests line carrying the template's own ❌ failure
|
|
735
|
+
// marker is folded in here — it is exactly as blocking per the agent's own
|
|
736
|
+
// documented contract, so the outcome receipt now always explains a FAIL,
|
|
737
|
+
// not only a CRITICAL-findings one.
|
|
738
|
+
const empiricalMatches = text.matchAll(/^[ \t]*(Build|Tests):[ \t]*(.*)$/gim);
|
|
739
|
+
for (const m of empiricalMatches) {
|
|
740
|
+
if (m[2].indexOf('❌') !== -1) {
|
|
741
|
+
criticalFindings.push(m[1] + ': ' + m[2].trim());
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
658
745
|
return { passed: passed, criticalFindings: criticalFindings };
|
|
659
746
|
}
|
|
660
747
|
|
|
@@ -796,7 +883,7 @@ async function runReview(args) {
|
|
|
796
883
|
const prompt = _buildReviewPrompt(task, diff);
|
|
797
884
|
const spawnArgs = Array.isArray(args.spawnArgs)
|
|
798
885
|
? args.spawnArgs
|
|
799
|
-
: ['--print', '--output-format', 'json', '--agent', identity.nativeName];
|
|
886
|
+
: ['--print', '--output-format', 'json', '--agent', identity.nativeName].concat(EXECUTOR_PERMISSION_BYPASS_ARGS);
|
|
800
887
|
const spawnResult = await claudeProcess.spawnClaudeAgent({
|
|
801
888
|
claudePath: args.claudePath,
|
|
802
889
|
args: spawnArgs,
|
|
@@ -1924,6 +2011,14 @@ async function _runTaskToResolution(ctx) {
|
|
|
1924
2011
|
// via its own try/finally).
|
|
1925
2012
|
throw dispatchErr;
|
|
1926
2013
|
}
|
|
2014
|
+
// Close the 'implementation' ledger activity dispatchTaskAttempt already
|
|
2015
|
+
// opened before this threw — the same persist-before-invoke ordering
|
|
2016
|
+
// that proves the attempt was durably recorded (the check just above)
|
|
2017
|
+
// also proves ledger.open already ran for it. Otherwise this activity
|
|
2018
|
+
// would stay open forever, exactly the gap the success path below also
|
|
2019
|
+
// fixes.
|
|
2020
|
+
_finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'implementation',
|
|
2021
|
+
{ status: 'failed', tokens: null, reason: dispatchErr.code || 'UNKNOWN' });
|
|
1927
2022
|
// Genuine spawn-level failure (spawnClaudeAgent itself threw) AFTER
|
|
1928
2023
|
// the attempt was already durably recorded — this DOES consume the
|
|
1929
2024
|
// attempt, exactly like a verification/review failure does.
|
|
@@ -1952,7 +2047,19 @@ async function _runTaskToResolution(ctx) {
|
|
|
1952
2047
|
continue;
|
|
1953
2048
|
}
|
|
1954
2049
|
|
|
1955
|
-
|
|
2050
|
+
// Close the 'implementation' ledger activity opened by dispatchTaskAttempt
|
|
2051
|
+
// — previously left open forever (confirmed against a real run: every
|
|
2052
|
+
// 'implementation'/'review' entry stayed status:'running' indefinitely,
|
|
2053
|
+
// even for tasks that had already reached a terminal 'failed' stage).
|
|
2054
|
+
// "done" here means the subprocess ran to completion and returned a
|
|
2055
|
+
// result, independent of whether verification/review later judges that
|
|
2056
|
+
// result a pass or fail — that judgment is recorded separately, on the
|
|
2057
|
+
// task's own outcome receipt (persistVerificationReviewOutcome below),
|
|
2058
|
+
// never on this activity's ledger status. Mirrors the 'task'-kind
|
|
2059
|
+
// control-only close below (finalizeTaskCheckpoint) in structure, but
|
|
2060
|
+
// carries real measured tokens instead of an always-null control signal.
|
|
2061
|
+
_finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'implementation',
|
|
2062
|
+
{ status: 'done', tokens: _measureTokensFromSpawnResult(dispatchResult.spawnResult) });
|
|
1956
2063
|
|
|
1957
2064
|
const verification = await runVerifications({
|
|
1958
2065
|
executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
|
|
@@ -1972,6 +2079,12 @@ async function _runTaskToResolution(ctx) {
|
|
|
1972
2079
|
claudePath: ctx.claudePath, projectDir: projectDir, diff: diff, taskTimeoutMs: ctx.taskTimeoutMs,
|
|
1973
2080
|
spawnArgs: ctx.reviewSpawnArgs,
|
|
1974
2081
|
});
|
|
2082
|
+
// Close the 'review' ledger activity runReview already opened — same
|
|
2083
|
+
// previously-open-forever gap the 'implementation' close above fixes,
|
|
2084
|
+
// same reasoning: "done" reflects the subprocess completing, not the
|
|
2085
|
+
// review's own pass/fail verdict (that lives on the outcome receipt).
|
|
2086
|
+
_finalizeDispatchLedgerActivity(executionRoot, runId, taskId, attemptNumber, 'review',
|
|
2087
|
+
{ status: 'done', tokens: _measureTokensFromSpawnResult(review.spawnResult) });
|
|
1975
2088
|
}
|
|
1976
2089
|
|
|
1977
2090
|
const outcome = store.persistVerificationReviewOutcome(executionRoot, runId, taskId, attemptNumber, verification, review);
|
|
@@ -2792,14 +2905,121 @@ async function execute(args) {
|
|
|
2792
2905
|
}
|
|
2793
2906
|
}
|
|
2794
2907
|
|
|
2908
|
+
// Reads the run's own token ledger (<executionRoot>/runs/<runId>/<runId>-
|
|
2909
|
+
// token-ledger.json, written by ../execution-ledger.js's open/close/fail/
|
|
2910
|
+
// skip) as a flat array of entries. No activity opened yet is a normal,
|
|
2911
|
+
// non-error state (returns []) — a present-but-unparseable file fails closed
|
|
2912
|
+
// instead, since that is evidence of a corrupted run, not an empty one.
|
|
2913
|
+
function _readRunLedgerEntries(executionRoot, runId) {
|
|
2914
|
+
const ledgerPath = path.join(executionRoot, 'runs', runId, runId + '-token-ledger.json');
|
|
2915
|
+
let raw;
|
|
2916
|
+
try {
|
|
2917
|
+
raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
2918
|
+
} catch (err) {
|
|
2919
|
+
if (err.code === 'ENOENT') return [];
|
|
2920
|
+
throw _err('LEDGER_READ_FAILED', 'status: failed to read token ledger at ' + ledgerPath + ': ' + err.message);
|
|
2921
|
+
}
|
|
2922
|
+
let parsed;
|
|
2923
|
+
try {
|
|
2924
|
+
parsed = JSON.parse(raw);
|
|
2925
|
+
} catch (err) {
|
|
2926
|
+
throw _err('LEDGER_CORRUPTED', 'status: token ledger at ' + ledgerPath + ' is not valid JSON: ' + err.message);
|
|
2927
|
+
}
|
|
2928
|
+
if (!Array.isArray(parsed)) {
|
|
2929
|
+
throw _err('LEDGER_CORRUPTED', 'status: token ledger at ' + ledgerPath + ' must be a JSON array, got ' + typeof parsed);
|
|
2930
|
+
}
|
|
2931
|
+
return parsed;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// Loads docs/token-pricing.json from the TARGET project (args.project), not
|
|
2935
|
+
// this toolkit's own checkout — the same file and project-relative
|
|
2936
|
+
// resolution lib/workflow-artifacts.js's pricing() uses for feature
|
|
2937
|
+
// Token-Estimate documents (see lib/workflow-control.js). A missing file
|
|
2938
|
+
// means "cost unavailable", not an error: not every consuming project ships
|
|
2939
|
+
// pricing data, and status must still report token counts for one that
|
|
2940
|
+
// doesn't. A present-but-invalid file fails closed.
|
|
2941
|
+
function _loadTokenPricing(projectDir) {
|
|
2942
|
+
const pricingPath = path.join(projectDir, 'docs', 'token-pricing.json');
|
|
2943
|
+
let raw;
|
|
2944
|
+
try {
|
|
2945
|
+
raw = fs.readFileSync(pricingPath, 'utf8');
|
|
2946
|
+
} catch (err) {
|
|
2947
|
+
if (err.code === 'ENOENT') return null;
|
|
2948
|
+
throw _err('PRICING_READ_FAILED', 'status: failed to read token pricing at ' + pricingPath + ': ' + err.message);
|
|
2949
|
+
}
|
|
2950
|
+
let parsed;
|
|
2951
|
+
try {
|
|
2952
|
+
parsed = JSON.parse(raw);
|
|
2953
|
+
} catch (err) {
|
|
2954
|
+
throw _err('PRICING_CORRUPTED', 'status: token pricing at ' + pricingPath + ' is not valid JSON: ' + err.message);
|
|
2955
|
+
}
|
|
2956
|
+
if (!parsed || !parsed.models || !Number.isFinite(parsed.usd_to_eur) || parsed.usd_to_eur <= 0) {
|
|
2957
|
+
throw _err('PRICING_CORRUPTED', 'status: token pricing at ' + pricingPath + ' is missing "models" or a valid "usd_to_eur" rate');
|
|
2958
|
+
}
|
|
2959
|
+
return parsed;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// USD cost for one ledger entry's measured tokens, using the estimated
|
|
2963
|
+
// 80/20 input/output split already established by lib/workflow-artifacts.js's
|
|
2964
|
+
// cost() for the same pricing file — unlike that helper, this never converts
|
|
2965
|
+
// to EUR (the field this feeds is totalCostUsd). Returns null (not 0) when
|
|
2966
|
+
// the entry's model is missing/unknown or its tokens are not a finite number
|
|
2967
|
+
// — an unattributable entry is excluded from the total, never coerced into a
|
|
2968
|
+
// zero-cost one.
|
|
2969
|
+
function _usdCostForLedgerEntry(tokens, model, pricing) {
|
|
2970
|
+
if (!Number.isFinite(tokens) || typeof model !== 'string' || model.length === 0) return null;
|
|
2971
|
+
const rate = pricing.models[model];
|
|
2972
|
+
if (!rate || !Number.isFinite(rate.input_per_1m_usd) || !Number.isFinite(rate.output_per_1m_usd)) return null;
|
|
2973
|
+
return tokens * (0.8 * rate.input_per_1m_usd + 0.2 * rate.output_per_1m_usd) / 1000000;
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// Null-compatibility (mirrors the convention already established by
|
|
2977
|
+
// src/claude/skills/implement-feature/SKILL.md's Step 7 and
|
|
2978
|
+
// lib/workflow-artifacts.js's pricing/cost helpers): a token value that is
|
|
2979
|
+
// null/undefined/not a finite number is "not yet measured", never coerced
|
|
2980
|
+
// into a real, observable zero. totalTokens/totalCostUsd stay null — not 0 —
|
|
2981
|
+
// when nothing measurable exists yet, so a caller can tell "nothing spent"
|
|
2982
|
+
// apart from "nothing reported". Every executor ledger entry is currently
|
|
2983
|
+
// opened with model: null (see _runTaskToResolution's ledger.open calls
|
|
2984
|
+
// above), so totalCostUsd is null whenever no pricing file is present OR
|
|
2985
|
+
// every entry's model is still unattributed — both are legitimate,
|
|
2986
|
+
// non-error outcomes, not a bug in this function.
|
|
2987
|
+
function _summarizeLedgerTotals(entries, projectDir) {
|
|
2988
|
+
let totalTokens = null;
|
|
2989
|
+
let totalCostUsd = null;
|
|
2990
|
+
let pricing;
|
|
2991
|
+
let pricingLoaded = false;
|
|
2992
|
+
|
|
2993
|
+
entries.forEach(function (entry) {
|
|
2994
|
+
const tokens = entry.phase_delta_tokens;
|
|
2995
|
+
if (!Number.isFinite(tokens)) return;
|
|
2996
|
+
totalTokens = (totalTokens || 0) + tokens;
|
|
2997
|
+
|
|
2998
|
+
if (!pricingLoaded) {
|
|
2999
|
+
pricing = _loadTokenPricing(projectDir);
|
|
3000
|
+
pricingLoaded = true;
|
|
3001
|
+
}
|
|
3002
|
+
if (!pricing) return;
|
|
3003
|
+
|
|
3004
|
+
const entryCost = _usdCostForLedgerEntry(tokens, entry.model, pricing);
|
|
3005
|
+
if (entryCost === null) return;
|
|
3006
|
+
totalCostUsd = (totalCostUsd || 0) + entryCost;
|
|
3007
|
+
});
|
|
3008
|
+
|
|
3009
|
+
return { totalTokens: totalTokens, totalCostUsd: totalCostUsd };
|
|
3010
|
+
}
|
|
3011
|
+
|
|
2795
3012
|
/**
|
|
2796
3013
|
* `status` command. Read-only summary of a run: counts of tasks per
|
|
2797
3014
|
* status, current run status, and total cost as reported by the ledger
|
|
2798
3015
|
* (the sole cost source). Never mutates state.
|
|
2799
3016
|
*
|
|
2800
|
-
*
|
|
2801
|
-
*
|
|
2802
|
-
*
|
|
3017
|
+
* Built entirely from three already-implemented data sources — no new Work
|
|
3018
|
+
* Breakdown task was required: store.readState (INFRA-TASK-BE-01) for task
|
|
3019
|
+
* statuses/runStatus, the run's own token-ledger.json
|
|
3020
|
+
* (INFRA-TASK-BE-02/../execution-ledger.js) for token counts, and the
|
|
3021
|
+
* target project's docs/token-pricing.json (already read the same way by
|
|
3022
|
+
* lib/workflow-artifacts.js) for USD cost attribution.
|
|
2803
3023
|
*
|
|
2804
3024
|
* @param {object} args
|
|
2805
3025
|
* @param {string} args.project - Absolute or project-relative repo path.
|
|
@@ -2808,9 +3028,50 @@ async function execute(args) {
|
|
|
2808
3028
|
* { protocolVersion, runId, runStatus,
|
|
2809
3029
|
* taskCounts: { pending, active, checkpointed, integrated, skipped, blocked },
|
|
2810
3030
|
* totalTokens, totalCostUsd }.
|
|
3031
|
+
* totalTokens/totalCostUsd are null (never 0) when nothing measurable has
|
|
3032
|
+
* been recorded yet, or (totalCostUsd only) when the project ships no
|
|
3033
|
+
* docs/token-pricing.json.
|
|
3034
|
+
* @throws {Error} DISPATCH_VALIDATION_ERROR for a malformed args.project/
|
|
3035
|
+
* args.runId (via _requireNonEmptyString, same as every other command in
|
|
3036
|
+
* this module); any error store.readState throws (STATE_NOT_FOUND,
|
|
3037
|
+
* STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
|
|
3038
|
+
* never silently defaulted; LEDGER_READ_FAILED/LEDGER_CORRUPTED/
|
|
3039
|
+
* PRICING_READ_FAILED/PRICING_CORRUPTED for an unreadable or unparseable
|
|
3040
|
+
* ledger or pricing file.
|
|
2811
3041
|
*/
|
|
2812
3042
|
async function status(args) {
|
|
2813
|
-
|
|
3043
|
+
args = args || {};
|
|
3044
|
+
_requireNonEmptyString(args.project, 'status: args.project');
|
|
3045
|
+
_requireNonEmptyString(args.runId, 'status: args.runId');
|
|
3046
|
+
|
|
3047
|
+
const projectDir = args.project;
|
|
3048
|
+
const runId = args.runId;
|
|
3049
|
+
const commonDir = _resolveCommonGitDir(projectDir);
|
|
3050
|
+
const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
|
|
3051
|
+
|
|
3052
|
+
// Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
|
|
3053
|
+
// fabricates an empty/default run for a corrupted/missing one.
|
|
3054
|
+
const state = store.readState(executionRoot, runId);
|
|
3055
|
+
|
|
3056
|
+
const taskCounts = { pending: 0, active: 0, checkpointed: 0, integrated: 0, skipped: 0, blocked: 0 };
|
|
3057
|
+
Object.keys(state.tasks).forEach(function (taskId) {
|
|
3058
|
+
const st = state.tasks[taskId].status;
|
|
3059
|
+
if (Object.prototype.hasOwnProperty.call(taskCounts, st)) {
|
|
3060
|
+
taskCounts[st] += 1;
|
|
3061
|
+
}
|
|
3062
|
+
});
|
|
3063
|
+
|
|
3064
|
+
const ledgerEntries = _readRunLedgerEntries(executionRoot, runId);
|
|
3065
|
+
const totals = _summarizeLedgerTotals(ledgerEntries, projectDir);
|
|
3066
|
+
|
|
3067
|
+
return {
|
|
3068
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
3069
|
+
runId: runId,
|
|
3070
|
+
runStatus: state.runStatus,
|
|
3071
|
+
taskCounts: taskCounts,
|
|
3072
|
+
totalTokens: totals.totalTokens,
|
|
3073
|
+
totalCostUsd: totals.totalCostUsd,
|
|
3074
|
+
};
|
|
2814
3075
|
}
|
|
2815
3076
|
|
|
2816
3077
|
/**
|
|
@@ -3726,4 +3987,6 @@ module.exports = {
|
|
|
3726
3987
|
DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
|
|
3727
3988
|
_buildProcessTag,
|
|
3728
3989
|
_findLiveTaggedProcess,
|
|
3990
|
+
_measureTokensFromSpawnResult,
|
|
3991
|
+
_finalizeDispatchLedgerActivity,
|
|
3729
3992
|
};
|