@worca/app 1.3.0 → 1.4.0
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/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
package/src/core/run-harness.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { EventEmitter } from 'node:events';
|
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
|
-
import { join, basename, resolve, sep, relative } from 'node:path';
|
|
20
|
+
import { join, basename, dirname, resolve, sep, relative } from 'node:path';
|
|
21
21
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
22
22
|
import { readFile, writeFile, readdir, mkdir, realpath, rename } from 'node:fs/promises';
|
|
23
23
|
|
|
@@ -40,8 +40,8 @@ import {
|
|
|
40
40
|
pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
|
|
41
41
|
memoryCaps,
|
|
42
42
|
} from './settings.mjs';
|
|
43
|
-
import { mountDirs, mountMemory, syncBack, memoryTotals, validateMemoryScope, withStoreLock,
|
|
44
|
-
import { memoryRoot, renderMemoryBlock, bumpScopeState } from './memory-store.mjs';
|
|
43
|
+
import { mountDirs, mountMemory, refreshMount, syncBack, memoryTotals, validateMemoryScope, withStoreLock, memoryRulesPath, memoryWorkPath, MEMORY_RULES_REL, MEMORY_INJECTED_ENTRY } from './memory-sync.mjs';
|
|
44
|
+
import { memoryRoot, renderMemoryBlock, bumpScopeState, readScopeState, memoryScopeReport, renderDefragBrief } from './memory-store.mjs';
|
|
45
45
|
import { readCostCapOverride, totalWindowSpendUsd, costWindowStart, recordCostDelta } from './cost-budget.mjs';
|
|
46
46
|
import {
|
|
47
47
|
writeRunManifest, readRunManifest, updateRunManifest, rmGuarded, rescueModifiedMounts,
|
|
@@ -55,7 +55,8 @@ import {
|
|
|
55
55
|
probeClaudeCapabilities, explainUnspawnableClaude,
|
|
56
56
|
} from './preflight.mjs';
|
|
57
57
|
import { fanoutCap, mapWithCap } from './fanout.mjs';
|
|
58
|
-
import { resolveStepModels, observeModelCost, resolveModelCost, modelCostConfig } from './config.mjs';
|
|
58
|
+
import { resolveStepModels, observeModelCost, resolveModelCost, modelCostConfig, readTeamMetricsPrefs } from './config.mjs';
|
|
59
|
+
import { bridgeCallsFor, forgetBridgeTag } from './bridge/telemetry.mjs';
|
|
59
60
|
import { readGuardrailSet } from './guardrail-store.mjs';
|
|
60
61
|
import { unionGuardrails, guardrailsToPermissionRules, mergePermissionRules } from './guardrails.mjs';
|
|
61
62
|
import { collectRequiredSkills, validateSkills, injectSkills, pluginSkillDirs } from './skills.mjs';
|
|
@@ -71,27 +72,27 @@ import {
|
|
|
71
72
|
REASON, pauseConsequences, describePauseReason,
|
|
72
73
|
} from './failure-policy.mjs';
|
|
73
74
|
import { recordRunMetrics } from './metrics/record.mjs';
|
|
75
|
+
// Team policy (team-policy design §6–§7): the document a run's cost gates fold in, its
|
|
76
|
+
// per-run state, and the off-policy findings the run log names at start.
|
|
77
|
+
import { resolveProjectPolicy, resolveWorkspacePolicy } from './policy/sync.mjs';
|
|
78
|
+
import { fieldsForRun, effectiveCap, deviationsFor } from './policy/effective.mjs';
|
|
79
|
+
import { writePolicyState, hasPipelineOverride, readTotalAck } from './policy/state.mjs';
|
|
80
|
+
import { installedPluginsMap, WORCA_VERSION as POLICY_WORCA_VERSION } from './policy/local.mjs';
|
|
81
|
+
import { readSettings as readRawSettings } from './settings.mjs';
|
|
74
82
|
|
|
75
83
|
// worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
|
|
76
84
|
// latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
|
|
77
85
|
// in agent-registry.mjs, which is the single source for the built-in agents dir).
|
|
78
86
|
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
79
87
|
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
* current/agents/*.meta.json. Returns the plugin name or null. try/catch
|
|
84
|
-
* throughout: no resolvable home / no lock / broken current => null (callers
|
|
85
|
-
* fall back to the generic "not installed" message).
|
|
86
|
-
* @param {string} key
|
|
87
|
-
* @returns {string|null}
|
|
88
|
-
*/
|
|
89
|
-
function findDisabledPluginFor(key) {
|
|
88
|
+
/** The disabled plugin that ships `<subdir>/<key>.meta.json`, or null. Shared by the
|
|
89
|
+
* agent preflight (`agents`) and the orchestrator's script preflight (`scripts`). */
|
|
90
|
+
export function findDisabledPluginFor(key, subdir = 'agents') {
|
|
90
91
|
try {
|
|
91
92
|
const lock = readPluginsLock();
|
|
92
93
|
for (const name of Object.keys(lock).sort()) {
|
|
93
94
|
if (!lock[name] || lock[name].enabled !== false) continue;
|
|
94
|
-
const dir = join(pluginCurrentDir(name),
|
|
95
|
+
const dir = join(pluginCurrentDir(name), subdir);
|
|
95
96
|
let files;
|
|
96
97
|
try { files = readdirSync(dir); } catch { continue; }
|
|
97
98
|
for (const f of files) {
|
|
@@ -318,6 +319,18 @@ function describeToolResults(raw) {
|
|
|
318
319
|
return lines;
|
|
319
320
|
}
|
|
320
321
|
|
|
322
|
+
/** The tools whose `file_path` can be a memory write. */
|
|
323
|
+
const MEMORY_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
324
|
+
|
|
325
|
+
/** The human text of a tool_result block: a string, or the first text block; `<tool_use_error>`
|
|
326
|
+
* tags stripped (the CLI wraps some errors in them). '' when there is none. */
|
|
327
|
+
function toolResultText(block) {
|
|
328
|
+
const c = block?.content;
|
|
329
|
+
const raw = typeof c === 'string' ? c
|
|
330
|
+
: Array.isArray(c) ? (c.find((x) => x?.type === 'text' && typeof x.text === 'string')?.text || '') : '';
|
|
331
|
+
return raw.replace(/<\/?tool_use_error>/g, '').trim();
|
|
332
|
+
}
|
|
333
|
+
|
|
321
334
|
/** A short, human-readable target for a tool call (file, command, pattern…). */
|
|
322
335
|
function toolTarget(name, input, projectDir) {
|
|
323
336
|
if (!input || typeof input !== 'object') return '';
|
|
@@ -544,6 +557,9 @@ export function scrubErrorRows(snapshot) {
|
|
|
544
557
|
* questions with their first option so downstream never sees gaps.
|
|
545
558
|
*/
|
|
546
559
|
export function normalizeClarifyAnswer(payload, questions) {
|
|
560
|
+
// A form answer is `{form, version, values}` and is NEVER flattened here: the
|
|
561
|
+
// "fill with the first option" fallback below is legacy-kind only (spec §5).
|
|
562
|
+
if (payload && typeof payload === 'object' && typeof payload.form === 'string' && payload.values) return [];
|
|
547
563
|
const arr = Array.isArray(payload?.answers)
|
|
548
564
|
? payload.answers
|
|
549
565
|
: Array.isArray(payload)
|
|
@@ -677,7 +693,9 @@ export class RunHarness extends EventEmitter {
|
|
|
677
693
|
this.pauseDetail = null; // the human detail behind pauseReason ('error': the clipped message)
|
|
678
694
|
// Team metrics (§4.4 interventions). Resume runs on a NEW instance, so the counters are
|
|
679
695
|
// stamped into the persisted resume point at every pause and re-seeded in resume().
|
|
680
|
-
|
|
696
|
+
// pausedMs: time the run spent parked (paused, or dead between a crash and its resume);
|
|
697
|
+
// pausedAt: the pause stamp resume() measures from (null while running).
|
|
698
|
+
this._metricsIv = { questions: 0, pauses: 0, resumes: 0, pausedMs: 0, pausedAt: null, lastPauseReason: null, lastPauseDetail: null };
|
|
681
699
|
this._metricsRecorded = false;
|
|
682
700
|
this._setupDone = false; // run()/resume() flip this right before _engineRun (setup replay)
|
|
683
701
|
this._rehydrated = true; // resume() clears this until the paused run is rehydrated (the 'resume' site)
|
|
@@ -699,12 +717,17 @@ export class RunHarness extends EventEmitter {
|
|
|
699
717
|
this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
|
|
700
718
|
this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
|
|
701
719
|
this.agentPrompts = null;
|
|
702
|
-
this.memory = null; // { root, mount, dirs, baseline } after _mountMemory
|
|
720
|
+
this.memory = null; // { root, mount, rules, dirs, baseline } after _mountMemory — mount = the WRITABLE copy (<pipeline.dir>/memory), rules = the read-only copy (<runCwd>/.claude/rules/worca)
|
|
703
721
|
this.memoryBlock = ''; // the ## Worca memory pointer block, rendered once per mount (files load natively — no per-spawn re-render)
|
|
704
722
|
this.memoryChanges = []; // Change[] — the durable ledger's `changes`
|
|
705
723
|
this._memoryWarned = new Set();
|
|
706
724
|
this._memoryTail = null; // per-run sync chain: one syncBack at a time (F1)
|
|
725
|
+
// Failed-write bookkeeping (memory-write-split design §4): executionId -> { calls: Map<toolUseId, key>,
|
|
726
|
+
// last: Map<key.id, { ...key, ok, reason }> }. Filled by _trackMemoryWrites from every stream frame,
|
|
727
|
+
// drained by _takeFailedMemoryWrites at sync time.
|
|
728
|
+
this._memoryWrites = new Map();
|
|
707
729
|
this._ledgerSeq = 0; // monotonic: two ledger writes must never share a temp name
|
|
730
|
+
this._pendingAudits = []; // audit lines an engine hook queued before the pipeline dir existed (_resolveTopology runs first); run() appends them right after "Pipeline created"
|
|
708
731
|
this.toolInstruction = '';
|
|
709
732
|
// Cap for the in-worktree graphify build (macOS has no timeout(1)).
|
|
710
733
|
// Resolution order: constructor option → WORCA_GRAPH_TIMEOUT_MS env → 120s.
|
|
@@ -749,7 +772,8 @@ export class RunHarness extends EventEmitter {
|
|
|
749
772
|
// detached run throws TypeError on the first this.state.branches[key] = … .
|
|
750
773
|
branches: {},
|
|
751
774
|
checkpointRefs: {},
|
|
752
|
-
memoryMount: null, // <
|
|
775
|
+
memoryMount: null, // <pipeline.dir>/memory after _mountMemory — the WRITABLE copy agents edit (--add-dir on every spawn)
|
|
776
|
+
memoryRules: null, // <runCwd>/.claude/rules/worca — the read-only copy the CLI loads natively
|
|
753
777
|
pauseReason: null, // mirrors this.pauseReason so getState() (a deep clone of state) carries it live
|
|
754
778
|
pauseDetail: null, // mirrors this.pauseDetail
|
|
755
779
|
// Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
|
|
@@ -777,15 +801,29 @@ export class RunHarness extends EventEmitter {
|
|
|
777
801
|
return false;
|
|
778
802
|
}
|
|
779
803
|
if (pq.validate) {
|
|
780
|
-
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
804
|
+
const out = pq.validate(payload);
|
|
805
|
+
// Two validator flavours, deliberately: the Auto proposal's returns the
|
|
806
|
+
// CLEAN value or null (§5.4 — the question stays open, silently), while a
|
|
807
|
+
// form's gate 3 returns a RESULT OBJECT carrying the field errors that
|
|
808
|
+
// POST /api/answer owes the client as 422. Only the latter throws.
|
|
809
|
+
if (out && typeof out === 'object' && typeof out.ok === 'boolean') {
|
|
810
|
+
if (!out.ok) {
|
|
811
|
+
this._log('orchestrator', 'warn', `answer() rejected: invalid answer for ${id} — the question stays open`);
|
|
812
|
+
const err = new Error('invalid answer');
|
|
813
|
+
err.code = 'INVALID_ANSWER';
|
|
814
|
+
err.errors = Array.isArray(out.errors) ? out.errors : [];
|
|
815
|
+
throw err;
|
|
816
|
+
}
|
|
817
|
+
this.pendingQuestion = null;
|
|
818
|
+
pq.resolve(out.payload);
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
if (out == null) {
|
|
784
822
|
this._log('orchestrator', 'warn', `answer() ignored: malformed payload for ${id} — the question stays open`);
|
|
785
823
|
return false;
|
|
786
824
|
}
|
|
787
825
|
this.pendingQuestion = null;
|
|
788
|
-
pq.resolve(
|
|
826
|
+
pq.resolve(out);
|
|
789
827
|
return true;
|
|
790
828
|
}
|
|
791
829
|
this.pendingQuestion = null;
|
|
@@ -957,6 +995,7 @@ export class RunHarness extends EventEmitter {
|
|
|
957
995
|
this.state.tools = tools;
|
|
958
996
|
this.stepModels = stepModels;
|
|
959
997
|
await this._resolveGuardrails();
|
|
998
|
+
await this._resolvePolicy();
|
|
960
999
|
this._log(
|
|
961
1000
|
'preflight',
|
|
962
1001
|
'info',
|
|
@@ -1063,6 +1102,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1063
1102
|
`Preflight: using **${tools.tool}**${tools.kind ? ` (${tools.kind})` : ''}.`,
|
|
1064
1103
|
);
|
|
1065
1104
|
}
|
|
1105
|
+
for (const line of this._pendingAudits.splice(0)) await appendAudit(this.pipeline.dir, line);
|
|
1066
1106
|
|
|
1067
1107
|
// 3) Ensure a git repo + checkpoint commit (per member on a workspace run).
|
|
1068
1108
|
if (this.isWorkspace) await this._ensureGitCheckpointAll();
|
|
@@ -1141,8 +1181,10 @@ export class RunHarness extends EventEmitter {
|
|
|
1141
1181
|
await this._assembleContext(resolvedSkills);
|
|
1142
1182
|
}
|
|
1143
1183
|
this._checkAbort();
|
|
1144
|
-
// 3f) Agent memory: mount the store
|
|
1145
|
-
//
|
|
1184
|
+
// 3f) Agent memory: mount the store twice — the read-only rules copy into
|
|
1185
|
+
// <runCwd>/.claude/rules/worca (the CLI loads it natively) and the writable copy into
|
|
1186
|
+
// <pipeline.dir>/memory (every spawn's --add-dir; the sync-back reads it) — and render the
|
|
1187
|
+
// pointer block every spawn carries, naming the writable copy. Pure fs work, both modes,
|
|
1146
1188
|
// mock included. AFTER 3e: the assembly rewrites injectedPaths and the mount registers
|
|
1147
1189
|
// itself into that map.
|
|
1148
1190
|
await this._mountMemory();
|
|
@@ -1315,7 +1357,21 @@ export class RunHarness extends EventEmitter {
|
|
|
1315
1357
|
this.state.stepper = safeParse(row.stepper);
|
|
1316
1358
|
this.state.tools = safeParse(row.tools);
|
|
1317
1359
|
this.state.branch = safeParse(row.branch);
|
|
1318
|
-
|
|
1360
|
+
// Parked time (autonomy = active ÷ (wall − paused)). A paused row is measured from the
|
|
1361
|
+
// stamp _completePaused wrote into the point; an interrupted row from the last heartbeat
|
|
1362
|
+
// (the last time the dead process was seen alive — reconcileStaleRunning keeps it for
|
|
1363
|
+
// this). A point written before the stamp existed falls back to the row's updated_at.
|
|
1364
|
+
// The same anchor closes every step clock a crash left running: the tail up to the
|
|
1365
|
+
// anchor is real work that the crash never folded, the rest of the gap is parked.
|
|
1366
|
+
const iv = rp.interventions && typeof rp.interventions === 'object' ? rp.interventions : {};
|
|
1367
|
+
const anchor = Date.parse(row.status === 'interrupted' ? (row.heartbeat_at || row.updated_at) : (iv.pausedAt || row.updated_at));
|
|
1368
|
+
const now = Date.now();
|
|
1369
|
+
const parkedMs = Number.isFinite(anchor) ? Math.max(0, now - anchor) : 0;
|
|
1370
|
+
this.state.steps = (steps || []).map((s) => {
|
|
1371
|
+
if (s.runningSince == null || !Number.isFinite(anchor)) return { ...s, runningSince: null };
|
|
1372
|
+
return { ...s, activeMs: (s.activeMs || 0) + Math.max(0, Math.min(anchor, now) - s.runningSince), runningSince: null };
|
|
1373
|
+
});
|
|
1374
|
+
this.state.totalActiveMs = sumStepActive(this.state.steps);
|
|
1319
1375
|
this.baseName = row.base_name;
|
|
1320
1376
|
this.planDatePrefix = row.date_prefix;
|
|
1321
1377
|
this.pipeline = { id: row.id, dir: rp.pipelineDir, promptText: row.prompt || '' };
|
|
@@ -1325,10 +1381,10 @@ export class RunHarness extends EventEmitter {
|
|
|
1325
1381
|
this.stepModels = rp.stepModels || null;
|
|
1326
1382
|
this.workflowId = rp.workflowId || this.workflowId;
|
|
1327
1383
|
// Pauses are counted only in _completePaused, so a crash-resume of an `interrupted`
|
|
1328
|
-
// run adds a resume but no pause (§4.4 decision 6).
|
|
1329
|
-
const iv = rp.interventions && typeof rp.interventions === 'object' ? rp.interventions : {};
|
|
1384
|
+
// run adds a resume but no pause (§4.4 decision 6). The stamp is consumed here.
|
|
1330
1385
|
this._metricsIv = {
|
|
1331
1386
|
questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: (iv.resumes | 0) + 1,
|
|
1387
|
+
pausedMs: (Number.isFinite(iv.pausedMs) ? iv.pausedMs : 0) + parkedMs, pausedAt: null,
|
|
1332
1388
|
lastPauseReason: iv.lastPauseReason ?? null, lastPauseDetail: iv.lastPauseDetail ?? null,
|
|
1333
1389
|
};
|
|
1334
1390
|
// The saved point carries the pause that produced it; a resumed run is running.
|
|
@@ -1340,6 +1396,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1340
1396
|
this.guardrailsId = rp.guardrailsId || this.guardrailsId;
|
|
1341
1397
|
this.state.guardrailsId = this.guardrailsId;
|
|
1342
1398
|
await this._resolveGuardrails();
|
|
1399
|
+
await this._resolvePolicy();
|
|
1343
1400
|
// Restore the EFFECTIVE instruction from the resume point — by dispatch time
|
|
1344
1401
|
// run() has replaced the detect-time tools.instruction with the in-worktree
|
|
1345
1402
|
// graph-build outcome (worktreeGraphInstruction() or ''). Falling back to
|
|
@@ -1738,6 +1795,74 @@ export class RunHarness extends EventEmitter {
|
|
|
1738
1795
|
* LATEST definition. A missing/deleted set fails OPEN to the Permissive
|
|
1739
1796
|
* (empty) policy with a loud warn — never an abort.
|
|
1740
1797
|
*/
|
|
1798
|
+
/**
|
|
1799
|
+
* Team policy (design §6, §9): the document that governs this run, folded for its kind
|
|
1800
|
+
* (workspaceRuns for a workspace target), plus the off-policy findings the run log names
|
|
1801
|
+
* once. Cache-first: only a project with no cache at all pays one bounded fetch. A missing,
|
|
1802
|
+
* unreadable or unsupported policy means local settings apply — loudly, never an abort.
|
|
1803
|
+
* Sets `this.policyRun` = { home, sha, fields, deviations, unattended } or null.
|
|
1804
|
+
*/
|
|
1805
|
+
async _resolvePolicy() {
|
|
1806
|
+
this.policyRun = null;
|
|
1807
|
+
this._policyPersisted = false;
|
|
1808
|
+
this._policyWarned = new Set();
|
|
1809
|
+
let r;
|
|
1810
|
+
try {
|
|
1811
|
+
r = this.isWorkspace
|
|
1812
|
+
? await resolveWorkspacePolicy(this.workspace?.id, { discover: 'if-missing' })
|
|
1813
|
+
: await resolveProjectPolicy(this.projectDir, { discover: 'if-missing' });
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
this._log('policy', 'warn', `team policy could not be read (${err?.message || err}); your local settings apply`);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
if (!r.ok) {
|
|
1819
|
+
if (r.reason === 'delegate-invalid' || r.reason === 'home-stale' || r.reason === 'unsupported' || r.code === 'DOC_UNKNOWN') {
|
|
1820
|
+
this._log('policy', 'warn', `team policy not applied — ${r.detail || r.reason}; your local settings apply`);
|
|
1821
|
+
}
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
const fields = fieldsForRun(r.doc, { workspaceRun: this.isWorkspace });
|
|
1825
|
+
let installed = {};
|
|
1826
|
+
try { installed = installedPluginsMap(); } catch { /* no plugins root yet */ }
|
|
1827
|
+
let metricsRecord = null;
|
|
1828
|
+
try { const tm = readTeamMetricsPrefs(projectKey(this.projectDir)); metricsRecord = tm ? tm.record !== false : null; } catch { /* optional */ }
|
|
1829
|
+
const stepModels = Object.entries(this.stepModels || {}).map(([role, sel]) => ({ role, model: sel?.model }));
|
|
1830
|
+
const deviations = deviationsFor(fields, {
|
|
1831
|
+
guardrailsId: this.guardrailsId, guardrailSet: this.guardrails ? { settings: this.guardrails } : null,
|
|
1832
|
+
stepModels, installed, worcaVersion: POLICY_WORCA_VERSION, metricsRecord,
|
|
1833
|
+
});
|
|
1834
|
+
this.policyRun = { home: r.home, homeDir: r.homeDir, sha: r.sha, fields, deviations: deviations.map((d) => d.code), unattended: !!this.auto };
|
|
1835
|
+
for (const w of r.warnings || []) this._log('policy', 'warn', `team policy ${r.home}: ${w}`);
|
|
1836
|
+
const cap = fields['cost.pipelineLimitUsd'];
|
|
1837
|
+
const tot = fields['cost.totalLimitUsd'];
|
|
1838
|
+
const caps = [cap ? `pipeline cap $${Number(cap.value).toFixed(2)} (${cap.kind}${cap.kind === 'soft' ? `, ${cap.onBreach || 'pause'}` : ''})` : null,
|
|
1839
|
+
tot ? `total cap $${Number(tot.value).toFixed(2)} (${tot.kind})` : null].filter(Boolean).join(' · ');
|
|
1840
|
+
this._log('policy', 'info', `team policy ${r.home}${r.sha ? ` @ ${String(r.sha).slice(0, 7)}` : ''}${r.delegated ? ` (followed by ${r.from})` : ''}${this.isWorkspace && Object.keys(r.doc.workspaceRuns || {}).length ? ' · workspace-run values' : ''}${caps ? ` · ${caps}` : ''}`);
|
|
1841
|
+
for (const d of deviations) this._log('policy', d.level === 'warn' ? 'warn' : 'info', `off-policy: ${d.text}`);
|
|
1842
|
+
if (this.auto && ((cap?.kind === 'soft' && (cap.onBreach || 'pause') === 'pause') || (tot?.kind === 'soft' && (tot.onBreach || 'pause') === 'pause'))) {
|
|
1843
|
+
this._log('policy', 'info', 'unattended run: a team soft cap warns instead of pausing (nobody can click "continue past")');
|
|
1844
|
+
}
|
|
1845
|
+
// Workspace runs never union member policies (design §6): say so once when a member is tighter.
|
|
1846
|
+
if (this.isWorkspace && cap) {
|
|
1847
|
+
for (const m of this.members || []) {
|
|
1848
|
+
try {
|
|
1849
|
+
const mr = await resolveProjectPolicy(m.projectDir, { discover: false });
|
|
1850
|
+
if (!mr.ok || mr.home === r.home) continue;
|
|
1851
|
+
const mc = fieldsForRun(mr.doc)['cost.pipelineLimitUsd'];
|
|
1852
|
+
if (mc && mc.value < cap.value) this._log('policy', 'info', `member ${mr.from} carries a tighter pipeline cap ($${Number(mc.value).toFixed(2)}, ${mr.home}); the workspace policy applies to this run`);
|
|
1853
|
+
} catch { /* informational only */ }
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/** First persist of the run's policy state (needs the pipeline row); later writes merge. */
|
|
1859
|
+
_persistPolicyState(patch = {}) {
|
|
1860
|
+
if (!this.pipeline?.id || !this.policyRun) return;
|
|
1861
|
+
const base = this._policyPersisted ? {} : { home: this.policyRun.home, sha: this.policyRun.sha, deviations: this.policyRun.deviations, unattended: this.policyRun.unattended };
|
|
1862
|
+
this._policyPersisted = true;
|
|
1863
|
+
try { writePolicyState(this.pipeline.id, { ...base, ...patch }); } catch (err) { this._log('policy', 'warn', `could not record policy state: ${err?.message || err}`); }
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1741
1866
|
async _resolveGuardrails() {
|
|
1742
1867
|
let set = await readGuardrailSet(this.guardrailsId || 'permissive');
|
|
1743
1868
|
if (!set) {
|
|
@@ -1858,7 +1983,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1858
1983
|
if (!this.pipeline?.dir) return;
|
|
1859
1984
|
try { await this._mountMemoryUnguarded({ resume }); }
|
|
1860
1985
|
catch (err) {
|
|
1861
|
-
this.memory = null; this.memoryBlock = ''; this.state.memoryMount = null;
|
|
1986
|
+
this.memory = null; this.memoryBlock = ''; this.state.memoryMount = null; this.state.memoryRules = null;
|
|
1862
1987
|
if (existsSync(this._memoryLedgerPath())) await this._writeMemoryLedger({ neutralised: true });
|
|
1863
1988
|
const why = String(err?.message || err).split('\n')[0];
|
|
1864
1989
|
// A defragment run IS its mount (B8): rethrow, and run()'s setup failure policy parks the run
|
|
@@ -1874,9 +1999,11 @@ export class RunHarness extends EventEmitter {
|
|
|
1874
1999
|
}
|
|
1875
2000
|
|
|
1876
2001
|
/**
|
|
1877
|
-
* Mount the memory store into this run
|
|
1878
|
-
*
|
|
1879
|
-
* run, the primary worktree otherwise)
|
|
2002
|
+
* Mount the memory store into this run twice: the read-only rules copy at
|
|
2003
|
+
* `<runCwd>/.claude/rules/worca` (where Claude Code discovers rules natively — run root on a
|
|
2004
|
+
* detached workspace run, the primary worktree otherwise) and the writable copy at
|
|
2005
|
+
* `<pipeline.dir>/memory` (the sync-back mount, outside every checkout). Always recomputed,
|
|
2006
|
+
* never the ledger's absolute paths.
|
|
1880
2007
|
* On resume, the previous segment's ledger is read first and its mount is synced back BEFORE
|
|
1881
2008
|
* anything else (§5 "resume of a paused run"): the sync is pure fs and needs neither git nor
|
|
1882
2009
|
* the tracked guard, so a guard that fails only NOW (git broken, the previous segment's agent
|
|
@@ -1884,10 +2011,15 @@ export class RunHarness extends EventEmitter {
|
|
|
1884
2011
|
*/
|
|
1885
2012
|
async _mountMemoryUnguarded({ resume }) {
|
|
1886
2013
|
const root = memoryRoot();
|
|
1887
|
-
// Pre-setup there is no run cwd, and the LIVE checkout must never take a
|
|
2014
|
+
// Pre-setup there is no run cwd, and the LIVE checkout must never take a rules copy.
|
|
1888
2015
|
const cwd = this.runCwd || null;
|
|
1889
2016
|
if (!cwd || cwd === this.projectDir) throw new Error('no run cwd to mount into');
|
|
1890
|
-
|
|
2017
|
+
if (!this.pipeline?.dir) throw new Error('no pipeline dir for the writable memory copy');
|
|
2018
|
+
// Two copies (memory-write-split design D1): the READ-ONLY rules copy inside the cwd, where Claude
|
|
2019
|
+
// Code loads it and refuses every write (`.claude` is a protected path); the WRITABLE copy — the
|
|
2020
|
+
// sync-back mount — under the pipeline dir, outside every checkout, reached by --add-dir.
|
|
2021
|
+
const rules = memoryRulesPath(cwd);
|
|
2022
|
+
const mount = memoryWorkPath(this.pipeline.dir);
|
|
1891
2023
|
// §8.8 scope of the record: 'runRoot' when the cwd IS the run root, else the member whose
|
|
1892
2024
|
// checkout is the cwd (the primary member on single and legacy-workspace runs).
|
|
1893
2025
|
const scope = (this.runRoot && cwd === this.runRoot) ? 'runRoot'
|
|
@@ -1900,9 +2032,9 @@ export class RunHarness extends EventEmitter {
|
|
|
1900
2032
|
try { ledger = JSON.parse(await readFile(this._memoryLedgerPath(), 'utf8')); } catch { ledger = null; }
|
|
1901
2033
|
if (ledger && ledger.baseline && Array.isArray(ledger.dirs)) {
|
|
1902
2034
|
this.memoryChanges = Array.isArray(ledger.changes) ? ledger.changes : [];
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
2035
|
+
// The interrupted segment's writes live at the LEDGER's mount: this dir since the write
|
|
2036
|
+
// split, the in-checkout `.claude/rules/worca` for a run paused before it (a pause keeps the
|
|
2037
|
+
// checkout, so that dir is still there). Sync whichever exists; the recomputed paths are used from here on.
|
|
1906
2038
|
const prev = (typeof ledger.mount === 'string' && ledger.mount !== mount && existsSync(ledger.mount)) ? ledger.mount : mount;
|
|
1907
2039
|
try {
|
|
1908
2040
|
await this._syncMemoryWith({ mount: prev, dirs: ledger.dirs, baseline: ledger.baseline, nodeId: 'resume', executionId: null, agentKey: null, label: 'the interrupted execution' });
|
|
@@ -1911,12 +2043,11 @@ export class RunHarness extends EventEmitter {
|
|
|
1911
2043
|
// through onError). If it ever does: do NOT remount over unsynced writes; keep the
|
|
1912
2044
|
// PREVIOUS mount + baseline so the next execution's sync retries them.
|
|
1913
2045
|
this._log('memory', 'warn', `memory: the interrupted execution's writes could not be synced (${err?.message || err}); keeping the previous mount`);
|
|
1914
|
-
this.memory = { root, mount: prev, dirs: ledger.dirs, baseline: ledger.baseline };
|
|
1915
|
-
this.state.memoryMount = prev;
|
|
1916
|
-
// Register
|
|
1917
|
-
// stay excluded from the commit and removed at teardown
|
|
1918
|
-
|
|
1919
|
-
if (prev === mount) await this._registerMemoryMount(scope);
|
|
2046
|
+
this.memory = { root, mount: prev, rules: null, dirs: ledger.dirs, baseline: ledger.baseline };
|
|
2047
|
+
this.state.memoryMount = prev; this.state.memoryRules = null;
|
|
2048
|
+
// Register in every case: the rules copy of the interrupted segment (if any) is still inside
|
|
2049
|
+
// the checkout and must stay excluded from the commit and removed at teardown. Idempotent.
|
|
2050
|
+
await this._registerMemoryMount(scope);
|
|
1920
2051
|
this._refreshMemoryBlock();
|
|
1921
2052
|
return;
|
|
1922
2053
|
}
|
|
@@ -1938,16 +2069,20 @@ export class RunHarness extends EventEmitter {
|
|
|
1938
2069
|
// EBUSY past the retries) leaves files under the cwd, and only the §8.8 entry keeps them out
|
|
1939
2070
|
// of the commit and gets them removed at teardown. Idempotent, harmless on failure.
|
|
1940
2071
|
await this._registerMemoryMount(scope);
|
|
1941
|
-
//
|
|
1942
|
-
//
|
|
1943
|
-
|
|
1944
|
-
//
|
|
1945
|
-
|
|
1946
|
-
|
|
2072
|
+
// The WRITABLE copy: a full copy of the mounted scopes (agents edit existing files in place;
|
|
2073
|
+
// syncBack diffs it against this baseline). Outside git — no sentinel.
|
|
2074
|
+
const m = await mountMemory({ root, mount, dirs, onError });
|
|
2075
|
+
// The READ-ONLY rules copy: the same files where the CLI discovers them. Its baseline is
|
|
2076
|
+
// irrelevant (nothing syncs back from it). `<rules>/.gitignore` = `*` keeps it out of an agent's
|
|
2077
|
+
// own `git add -A`, a staging pre-commit hook, snapshotWorktreePatch's bare `git add -A` and the
|
|
2078
|
+
// reviewer's `git status`; the §8.8 `:(exclude)` pathspec stays as defence in depth.
|
|
2079
|
+
await mountMemory({ root, mount: rules, dirs, onError, gitIgnore: true });
|
|
2080
|
+
this.memory = { root, mount, rules, dirs, baseline: m.baseline };
|
|
1947
2081
|
this.state.memoryMount = mount;
|
|
2082
|
+
this.state.memoryRules = rules;
|
|
1948
2083
|
this._refreshMemoryBlock();
|
|
1949
2084
|
await this._writeMemoryLedger();
|
|
1950
|
-
this._log('memory', 'info', `Memory mounted
|
|
2085
|
+
this._log('memory', 'info', `Memory mounted: ${m.files} file(s) across ${dirs.length} scope(s) — loaded from ${rules}, written to ${mount}`);
|
|
1951
2086
|
}
|
|
1952
2087
|
|
|
1953
2088
|
/**
|
|
@@ -1996,8 +2131,11 @@ export class RunHarness extends EventEmitter {
|
|
|
1996
2131
|
if (!this.memory) return Promise.resolve(null);
|
|
1997
2132
|
const job = async () => {
|
|
1998
2133
|
if (!this.memory) return null;
|
|
2134
|
+
// Every frame of the execution has arrived (the runner resolved before _afterExecution); a
|
|
2135
|
+
// null executionId is the run-end sync and drains what unfinished executions left behind.
|
|
2136
|
+
const failed = this._takeFailedMemoryWrites(ctx.executionId ?? null);
|
|
1999
2137
|
try {
|
|
2000
|
-
return await this._syncMemoryWith({ ...this.memory, nodeId: ctx.nodeId, executionId: ctx.executionId, agentKey: nc?.key ?? null, label: nc?.key || ctx.label || ctx.nodeId });
|
|
2138
|
+
return await this._syncMemoryWith({ ...this.memory, nodeId: ctx.nodeId, executionId: ctx.executionId, agentKey: nc?.key ?? null, label: nc?.key || ctx.label || ctx.nodeId, failed });
|
|
2001
2139
|
} catch (err) {
|
|
2002
2140
|
this._log('memory', 'warn', `memory sync failed after ${ctx.executionId}: ${err?.message || err}`);
|
|
2003
2141
|
return null;
|
|
@@ -2007,7 +2145,7 @@ export class RunHarness extends EventEmitter {
|
|
|
2007
2145
|
return this._memoryTail;
|
|
2008
2146
|
}
|
|
2009
2147
|
|
|
2010
|
-
async _syncMemoryWith({ mount, dirs, baseline, nodeId, executionId, agentKey, label }) {
|
|
2148
|
+
async _syncMemoryWith({ mount, dirs, baseline, nodeId, executionId, agentKey, label, failed = [] }) {
|
|
2011
2149
|
const now = new Date().toISOString();
|
|
2012
2150
|
const res = await syncBack({
|
|
2013
2151
|
root: memoryRoot(), mount, dirs, baseline, source: `${this.memoryScope ? 'defrag' : 'run'}:${this.pipeline.id}`, now,
|
|
@@ -2018,26 +2156,119 @@ export class RunHarness extends EventEmitter {
|
|
|
2018
2156
|
// an OLD instance can never race a resumed one because the scheduler drains in-flight
|
|
2019
2157
|
// executions before the run reports 'paused' (scheduler.mjs, the pause drain).
|
|
2020
2158
|
if (this.memory && this.memory.mount === mount) this.memory.baseline = res.baseline;
|
|
2021
|
-
if (res.total || res.rejected.length) {
|
|
2159
|
+
if (res.total || res.rejected.length || failed.length) {
|
|
2022
2160
|
this.memoryChanges.push({
|
|
2023
2161
|
executionId, nodeId, agentKey, at: now,
|
|
2024
|
-
added: res.added, modified: res.modified, deleted: res.deleted, rejected: res.rejected,
|
|
2162
|
+
added: res.added, modified: res.modified, deleted: res.deleted, rejected: res.rejected, failed,
|
|
2025
2163
|
});
|
|
2026
2164
|
const head = `Memory: +${res.added.length} ~${res.modified.length} -${res.deleted.length}` +
|
|
2027
|
-
`${res.rejected.length ? ` (${res.rejected.length} rejected)` : ''} by ${label}`;
|
|
2028
|
-
const name = (r) => `${r.scope}
|
|
2165
|
+
`${res.rejected.length ? ` (${res.rejected.length} rejected)` : ''}${failed.length ? ` (${failed.length} failed)` : ''} by ${label}`;
|
|
2166
|
+
const name = (r) => `${r.scope ? `${r.scope}/` : ''}${r.name}.md`;
|
|
2029
2167
|
const details = [
|
|
2030
2168
|
...res.added.map((r) => `added ${name(r)}`), ...res.modified.map((r) => `updated ${name(r)}`),
|
|
2031
2169
|
...res.deleted.map((r) => `deleted ${name(r)}`), ...res.rejected.map((r) => `rejected ${name(r)} — ${r.reason}`),
|
|
2170
|
+
...failed.map((r) => `failed ${name(r)} — ${r.reason}`),
|
|
2032
2171
|
];
|
|
2033
2172
|
this._log('memory', 'info', `${head}: ${details.join('; ')}`, { nodeId, executionId });
|
|
2173
|
+
// A failed write is the one memory outcome nobody asked for: warn, per file, so it is visible in
|
|
2174
|
+
// the run log without opening the 300 KB transcript.
|
|
2175
|
+
for (const r of failed) this._log('memory', 'warn', `memory: ${name(r)} written by ${label} never reached the store — ${r.reason}`, { nodeId, executionId });
|
|
2034
2176
|
await appendAudit(this.pipeline.dir, `${head}: ${details.join('; ')}`).catch(() => {});
|
|
2177
|
+
await this._recordFailedWrites(failed, now);
|
|
2178
|
+
}
|
|
2179
|
+
// The rules copy the NEXT execution loads must carry what this sync stored (the store is the
|
|
2180
|
+
// authority: it also holds Ask/UI writes made mid-run). Non-destructive and per-file atomic, so
|
|
2181
|
+
// a Task sub-agent spawning right now never reads a torn file; never touches the sentinel.
|
|
2182
|
+
if (res.total && this.memory && this.memory.mount === mount && this.memory.rules) {
|
|
2183
|
+
try {
|
|
2184
|
+
const { failed: stale } = await withStoreLock(memoryRoot(), () => refreshMount({ root: memoryRoot(), mount: this.memory.rules, dirs, onError: (p, err) => this._memoryReadWarn(p, err) }));
|
|
2185
|
+
if (stale.length) this._log('memory', 'warn', `memory: the rules copy could not be refreshed for ${stale.join(', ')} — the next agent loads the previous text`);
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
this._log('memory', 'warn', `memory: the rules copy could not be refreshed: ${err?.message || err}`);
|
|
2188
|
+
}
|
|
2035
2189
|
}
|
|
2036
2190
|
if (this.memory && this.memory.mount === mount) await this._writeMemoryLedger();
|
|
2037
2191
|
return res;
|
|
2038
2192
|
}
|
|
2039
2193
|
|
|
2040
|
-
/** `
|
|
2194
|
+
/** Bump the `.state` counters of every scope a failed write named (memory-write-split design D9).
|
|
2195
|
+
* A write beside the scope dirs (`scope: ''`, or a rel that is not mounted) is reported but counted
|
|
2196
|
+
* against no scope. Best-effort: a counter that cannot be written is a warn line, never a throw. */
|
|
2197
|
+
async _recordFailedWrites(failed, now) {
|
|
2198
|
+
if (!failed.length || !this.memory) return;
|
|
2199
|
+
const byRel = new Map();
|
|
2200
|
+
for (const f of failed) if (f.scope) byRel.set(f.scope, (byRel.get(f.scope) || 0) + 1);
|
|
2201
|
+
for (const [rel, n] of byRel) {
|
|
2202
|
+
const d = this.memory.dirs.find((x) => x.rel === rel);
|
|
2203
|
+
if (!d) continue;
|
|
2204
|
+
try {
|
|
2205
|
+
await withStoreLock(memoryRoot(), async () => {
|
|
2206
|
+
const st = await readScopeState(memoryRoot(), d.scope);
|
|
2207
|
+
await bumpScopeState(memoryRoot(), d.scope, { failedWrites: (Number(st.failedWrites) || 0) + n, lastFailedAt: now, lastFailedRunId: this.pipeline.id });
|
|
2208
|
+
});
|
|
2209
|
+
} catch (err) {
|
|
2210
|
+
this._log('memory', 'warn', `memory: failed-write counter not updated for ${rel}: ${err?.message || err}`);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
/** Classify a tool call's target: `{ id, where, scope, name }` when it sits under the writable copy
|
|
2216
|
+
* (`where: 'memory'`) or the read-only rules copy (`where: 'rules'`), else null. `scope` is the
|
|
2217
|
+
* mounted rel the path starts with, else its dirname inside the copy ('' at the copy's root) — a
|
|
2218
|
+
* write beside the scope dirs is still reported. Relative paths resolve against the run cwd, which
|
|
2219
|
+
* is every spawn's cwd. */
|
|
2220
|
+
_memoryWriteKey(p) {
|
|
2221
|
+
if (typeof p !== 'string' || !p || !this.memory) return null;
|
|
2222
|
+
const abs = resolve(this.runCwd || this.workDir || this.projectDir, p);
|
|
2223
|
+
for (const [where, base] of [['memory', this.memory.mount], ['rules', this.memory.rules]]) {
|
|
2224
|
+
if (!base || !(abs === base || abs.startsWith(base + sep))) continue;
|
|
2225
|
+
const rel = relative(base, abs).split(sep).join('/');
|
|
2226
|
+
const d = this.memory.dirs.find((x) => rel === x.rel || rel.startsWith(`${x.rel}/`));
|
|
2227
|
+
const dn = dirname(rel);
|
|
2228
|
+
return { id: `${where}:${rel}`, where, scope: d ? d.rel : (dn === '.' ? '' : dn), name: basename(rel).replace(/\.md$/i, '') };
|
|
2229
|
+
}
|
|
2230
|
+
return null;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
_trackMemoryWrites(raw, attr) {
|
|
2234
|
+
if (!this.memory) return;
|
|
2235
|
+
const content = raw?.message?.content;
|
|
2236
|
+
if (!Array.isArray(content)) return;
|
|
2237
|
+
const exec = attr?.executionId ?? '(no execution)';
|
|
2238
|
+
for (const b of content) {
|
|
2239
|
+
if (b?.type === 'tool_use' && MEMORY_WRITE_TOOLS.has(b.name) && typeof b.id === 'string') {
|
|
2240
|
+
const key = this._memoryWriteKey(b.input?.file_path || b.input?.path || b.input?.notebook_path);
|
|
2241
|
+
if (!key) continue;
|
|
2242
|
+
const rec = this._memoryWrites.get(exec) || { calls: new Map(), last: new Map() };
|
|
2243
|
+
rec.calls.set(b.id, key);
|
|
2244
|
+
this._memoryWrites.set(exec, rec);
|
|
2245
|
+
} else if (b?.type === 'tool_result' && typeof b.tool_use_id === 'string') {
|
|
2246
|
+
const rec = this._memoryWrites.get(exec);
|
|
2247
|
+
const key = rec?.calls.get(b.tool_use_id);
|
|
2248
|
+
if (!key) continue;
|
|
2249
|
+
// 400: the CLI's refusal quotes the absolute path and ends with the cause ("… which is a
|
|
2250
|
+
// sensitive file.") — a deep run-root path must not clip the cause away.
|
|
2251
|
+
rec.last.set(key.id, { ...key, ok: !b.is_error, reason: b.is_error ? clip(toolResultText(b), 400) : '' });
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/** The keys whose LAST write outcome in `executionId` was an error, as `[{ scope, name, reason }]`
|
|
2257
|
+
* (a retry that succeeded clears the failure); that execution's bookkeeping is dropped. `null`
|
|
2258
|
+
* drains EVERY pending execution — the run-end sync, so an execution the run never finished
|
|
2259
|
+
* (stop, error) still reports (design D13). A rules-copy write names its own cause first. */
|
|
2260
|
+
_takeFailedMemoryWrites(executionId) {
|
|
2261
|
+
const recs = executionId == null ? [...this._memoryWrites.values()] : [this._memoryWrites.get(executionId)].filter(Boolean);
|
|
2262
|
+
if (executionId == null) this._memoryWrites.clear(); else this._memoryWrites.delete(executionId);
|
|
2263
|
+
const out = [];
|
|
2264
|
+
for (const rec of recs) for (const v of rec.last.values()) {
|
|
2265
|
+
if (v.ok) continue;
|
|
2266
|
+
out.push({ scope: v.scope, name: v.name, reason: v.where === 'rules' ? `written into the read-only rules copy — ${v.reason}` : v.reason });
|
|
2267
|
+
}
|
|
2268
|
+
return out;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/** `{ mount, rules, dirs, baseline, changes }` — best-effort, atomic via temp + rename. */
|
|
2041
2272
|
async _writeMemoryLedger({ neutralised = false } = {}) {
|
|
2042
2273
|
if (!this.pipeline?.dir || (!this.memory && !neutralised)) return;
|
|
2043
2274
|
const file = this._memoryLedgerPath();
|
|
@@ -2045,13 +2276,29 @@ export class RunHarness extends EventEmitter {
|
|
|
2045
2276
|
// no baseline, so a later resume has nothing stale to diff against (a missing mount dir
|
|
2046
2277
|
// must never read as "the run deleted every file").
|
|
2047
2278
|
const payload = neutralised
|
|
2048
|
-
? { mount: null, dirs: [], baseline: {}, changes: this.memoryChanges }
|
|
2049
|
-
: { mount: this.memory.mount, dirs: this.memory.dirs, baseline: this.memory.baseline, changes: this.memoryChanges };
|
|
2279
|
+
? { mount: null, rules: null, dirs: [], baseline: {}, changes: this.memoryChanges }
|
|
2280
|
+
: { mount: this.memory.mount, rules: this.memory.rules, dirs: this.memory.dirs, baseline: this.memory.baseline, changes: this.memoryChanges };
|
|
2050
2281
|
const tmp = `${file}.tmp-${process.pid}-${++this._ledgerSeq}`;
|
|
2051
2282
|
try { await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); await rename(tmp, file); }
|
|
2052
2283
|
catch (err) { this._log('memory', 'warn', `memory ledger not written: ${err?.message || err}`); }
|
|
2053
2284
|
}
|
|
2054
2285
|
|
|
2286
|
+
/** The `## Memory health` section a defragment run appends to its task document: the reasons the
|
|
2287
|
+
* scope is flagged and the budgets a finished defragment must meet — read from the STORE (what
|
|
2288
|
+
* Settings → Memory shows), which the mount mirrors at this point. '' on every other run.
|
|
2289
|
+
* Best-effort: a store read failure costs the agent its brief, never the run. */
|
|
2290
|
+
async _defragBrief() {
|
|
2291
|
+
if (!this.memoryScope || !this.memory?.dirs?.length) return '';
|
|
2292
|
+
try {
|
|
2293
|
+
const caps = memoryCaps();
|
|
2294
|
+
const { health } = await memoryScopeReport(memoryRoot(), this.memory.dirs[0].scope, caps, { onError: (p, err) => this._memoryReadWarn(p, err) });
|
|
2295
|
+
return renderDefragBrief(health, caps);
|
|
2296
|
+
} catch (err) {
|
|
2297
|
+
this._log('memory', 'warn', `memory: the defragment brief could not be built: ${err?.message || err}`);
|
|
2298
|
+
return '';
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2055
2302
|
/** A finished defragment run resets the scope's counters (spec §5, §7): called on the `done`
|
|
2056
2303
|
* arms only, after _buildResults' final sync. `this.memory.dirs[0]` is the one mounted scope.
|
|
2057
2304
|
* Amendment B31: a run whose ledger holds ANY rejected write did not produce the scope the
|
|
@@ -2069,7 +2316,7 @@ export class RunHarness extends EventEmitter {
|
|
|
2069
2316
|
}
|
|
2070
2317
|
const now = new Date().toISOString();
|
|
2071
2318
|
try {
|
|
2072
|
-
await withStoreLock(memoryRoot(), () => bumpScopeState(memoryRoot(), d.scope, { lastDefragAt: now, lastDefragRunId: this.pipeline.id, writesSinceDefrag: 0 }));
|
|
2319
|
+
await withStoreLock(memoryRoot(), () => bumpScopeState(memoryRoot(), d.scope, { lastDefragAt: now, lastDefragRunId: this.pipeline.id, writesSinceDefrag: 0, failedWrites: 0, lastFailedAt: null, lastFailedRunId: null }));
|
|
2073
2320
|
this._log('memory', 'info', `Memory: ${d.label} defragmented — write counter reset`);
|
|
2074
2321
|
await appendAudit(this.pipeline.dir, `Memory: ${d.label} defragmented by this run.`).catch(() => {});
|
|
2075
2322
|
} catch (err) {
|
|
@@ -2748,27 +2995,80 @@ export class RunHarness extends EventEmitter {
|
|
|
2748
2995
|
* raised limit or a window reset takes effect at the next step (F9). */
|
|
2749
2996
|
_checkCostLimits() {
|
|
2750
2997
|
if (!this.pipeline?.id) return; // pre-createPipeline: nothing to meter
|
|
2751
|
-
|
|
2998
|
+
this._persistPolicyState(); // first boundary with a row: home/sha/deviations land
|
|
2999
|
+
const teamFields = this.policyRun?.fields || {};
|
|
3000
|
+
const home = this.policyRun?.home || null;
|
|
2752
3001
|
// resume() rehydrates state.steps but not state.totalCostUsd, so the row
|
|
2753
3002
|
// total reads $0 until the first cost event of the resumed run. Take the
|
|
2754
3003
|
// larger of the two so a resumed over-cap pipeline cannot run one free step.
|
|
2755
3004
|
const spentHere = Math.max(this.state.totalCostUsd || 0, sumStepCosts(this.state.steps));
|
|
2756
|
-
|
|
2757
|
-
|
|
3005
|
+
// Team policy (design §7): the tighter of the developer's cap and a soft team cap applies;
|
|
3006
|
+
// a team default only starts the developer off. `binding` says whose number tripped.
|
|
3007
|
+
const teamPipe = teamFields['cost.pipelineLimitUsd'] || null;
|
|
3008
|
+
const pipe = effectiveCap({ local: pipelineCostLimitUsd(), team: teamPipe });
|
|
3009
|
+
// The developer's own cap (or a team DEFAULT, which is the same thing): the existing
|
|
3010
|
+
// pause + the existing per-pipeline override. A team-bound fold has no "own" cap here.
|
|
3011
|
+
const ownCap = pipe.binding === 'team' ? null : pipe.cap;
|
|
3012
|
+
if (ownCap != null && spentHere >= ownCap && !readCostCapOverride(this.pipeline.id)) {
|
|
2758
3013
|
this._capReached(REASON.COST_PIPELINE,
|
|
2759
|
-
`pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${
|
|
3014
|
+
`pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${ownCap.toFixed(2)})`);
|
|
3015
|
+
}
|
|
3016
|
+
// The team SOFT cap, whether or not it is the tighter number: the local override never
|
|
3017
|
+
// bypasses it — only the team override ("continue past team cap") does.
|
|
3018
|
+
if (teamPipe && teamPipe.kind === 'soft' && spentHere >= teamPipe.value && !hasPipelineOverride(this.pipeline.id)) {
|
|
3019
|
+
const detail = `team cost cap reached ($${spentHere.toFixed(2)} >= $${Number(teamPipe.value).toFixed(2)}, ${home})`;
|
|
3020
|
+
this._teamCapBreach('pipeline', teamPipe, detail, REASON.COST_PIPELINE_POLICY);
|
|
2760
3021
|
}
|
|
2761
|
-
const
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
3022
|
+
const period = this._effectiveResetPeriod();
|
|
3023
|
+
const tot = effectiveCap({ local: totalCostLimitUsd(), team: teamFields['cost.totalLimitUsd'] || null });
|
|
3024
|
+
if (tot.cap != null) {
|
|
3025
|
+
const windowStartMs = costWindowStart(new Date(), period).getTime();
|
|
3026
|
+
const spent = totalWindowSpendUsd(windowStartMs);
|
|
3027
|
+
if (spent >= tot.cap) {
|
|
3028
|
+
const w = period === 'weekly' ? 'week' : 'month';
|
|
3029
|
+
if (tot.binding === 'team') {
|
|
3030
|
+
const ack = readTotalAck(projectKey(this.policyRun.homeDir || this.projectDir), home, windowStartMs);
|
|
3031
|
+
if (ack) {
|
|
3032
|
+
// Acknowledged once for this window (design §7): the run proceeds and the record says so.
|
|
3033
|
+
if (!this._policyWarned.has('total-ack')) { this._policyWarned.add('total-ack'); this._persistPolicyState({ overrides: ['total'], ...(ack.reason ? { reason: ack.reason } : {}) }); }
|
|
3034
|
+
} else {
|
|
3035
|
+
const detail = `team total cap reached ($${spent.toFixed(2)} >= $${tot.cap.toFixed(2)} this ${w}, ${home})`;
|
|
3036
|
+
this._teamCapBreach('total', tot.team, detail, REASON.COST_TOTAL_POLICY);
|
|
3037
|
+
}
|
|
3038
|
+
} else {
|
|
3039
|
+
this._capReached(REASON.COST_TOTAL,
|
|
3040
|
+
`total cost limit reached ($${spent.toFixed(2)} >= $${tot.cap.toFixed(2)} this ${w})`);
|
|
3041
|
+
}
|
|
2768
3042
|
}
|
|
2769
3043
|
}
|
|
2770
3044
|
}
|
|
2771
3045
|
|
|
3046
|
+
/** The reset period: the developer's when stored, else a team default, else monthly. */
|
|
3047
|
+
_effectiveResetPeriod() {
|
|
3048
|
+
const stored = readRawSettings().costLimitResetPeriod;
|
|
3049
|
+
if (stored === 'weekly' || stored === 'monthly') return stored;
|
|
3050
|
+
const team = this.policyRun?.fields?.['cost.resetPeriod'];
|
|
3051
|
+
return team && (team.value === 'weekly' || team.value === 'monthly') ? team.value : costLimitResetPeriod();
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
/**
|
|
3055
|
+
* A soft team cap was hit and nobody has continued past it. `onBreach: warn`, and any
|
|
3056
|
+
* unattended (--yes) run, log ONE line and go on with `exceeded` recorded; otherwise the
|
|
3057
|
+
* run pauses on the policy reason so the resume flow can offer "continue past".
|
|
3058
|
+
*/
|
|
3059
|
+
_teamCapBreach(which, team, detail, reason) {
|
|
3060
|
+
const breach = team?.onBreach || 'pause';
|
|
3061
|
+
if (breach === 'warn' || this.auto) {
|
|
3062
|
+
if (this._policyWarned.has(which)) return;
|
|
3063
|
+
this._policyWarned.add(which);
|
|
3064
|
+
const why = breach === 'warn' ? 'the policy says warn' : 'unattended run, nobody can continue past a pause';
|
|
3065
|
+
this._log('policy', 'warn', `${detail} — continuing: ${why}`);
|
|
3066
|
+
this._persistPolicyState({ exceeded: [which] });
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
this._capReached(reason, detail);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
2772
3072
|
/** The BUDGET site (failure-policy.mjs): a cost cap was reached at a step
|
|
2773
3073
|
* boundary. Unlike the catch-block sites this throws itself — its caller is
|
|
2774
3074
|
* the boundary gate. The audit line is required: _completePaused suppresses
|
|
@@ -2908,7 +3208,8 @@ export class RunHarness extends EventEmitter {
|
|
|
2908
3208
|
* Freezes the active-time clock while blocked on the user (active-time-only).
|
|
2909
3209
|
* @returns {Promise<any>} the answer payload
|
|
2910
3210
|
*/
|
|
2911
|
-
async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo, workflow,
|
|
3211
|
+
async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo, workflow,
|
|
3212
|
+
askId, form, version, title, surface, data, layout, answerSchema, fileRefs, files, autoValues, validate }) {
|
|
2912
3213
|
this._checkAbort();
|
|
2913
3214
|
// No interactive prompt may OPEN on a pausing run. pause() rejects only the
|
|
2914
3215
|
// prompt that is currently open; a queued ask (a parallel sibling's questions
|
|
@@ -2940,6 +3241,15 @@ export class RunHarness extends EventEmitter {
|
|
|
2940
3241
|
...(deliveryNo != null ? { deliveryNo } : {}),
|
|
2941
3242
|
...(holdNo != null ? { holdNo } : {}),
|
|
2942
3243
|
...(workflow !== undefined ? { workflow } : {}),
|
|
3244
|
+
// The ask-form envelope (spec §4, ruling X1) rides the EXISTING 'question'
|
|
3245
|
+
// frame — no new transport, no new slot. `id` (already emitted above) is the
|
|
3246
|
+
// ANSWER token; `askId` is the route-safe file token and they are never
|
|
3247
|
+
// interchangeable. `validate` and `autoValues` are arguments only and must
|
|
3248
|
+
// never reach a socket.
|
|
3249
|
+
...(kind === 'form'
|
|
3250
|
+
? { askId, form, version, title, surface: surface || 'any', data, layout, answerSchema,
|
|
3251
|
+
fileRefs: fileRefs || [], files: files || [] }
|
|
3252
|
+
: {}),
|
|
2943
3253
|
});
|
|
2944
3254
|
this._metricsIv.questions += 1;
|
|
2945
3255
|
|
|
@@ -2956,6 +3266,13 @@ export class RunHarness extends EventEmitter {
|
|
|
2956
3266
|
this._log('orchestrator', 'info', `auto-accepting workflow proposal ${id}`);
|
|
2957
3267
|
return { decision: 'accept' };
|
|
2958
3268
|
}
|
|
3269
|
+
if (kind === 'form') {
|
|
3270
|
+
// D10: a form ask is auto-answered with the form's AUTO ANSWER, which
|
|
3271
|
+
// gate 1 proved passes gate 3 — so an unattended run neither hangs nor
|
|
3272
|
+
// produces an invalid answer. No pending question is installed.
|
|
3273
|
+
this._log('orchestrator', 'info', `auto-answering form ${id} (${form})`);
|
|
3274
|
+
return { form, version, values: autoValues && typeof autoValues === 'object' ? autoValues : {} };
|
|
3275
|
+
}
|
|
2959
3276
|
if (kind === 'clarify' || kind === 'questions') {
|
|
2960
3277
|
this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
|
|
2961
3278
|
return {
|
|
@@ -3604,6 +3921,7 @@ export class RunHarness extends EventEmitter {
|
|
|
3604
3921
|
const cost = costCfg
|
|
3605
3922
|
? resolveModelCost(attr.model, rawCost, e.raw.usage, costCfg)
|
|
3606
3923
|
: rawCost;
|
|
3924
|
+
if (isResult) this._recordBridgeCalls(attr?.stepKey, attr?.executionId);
|
|
3607
3925
|
if (Number.isFinite(cost)) this._recordCost(cost, attr?.stepKey);
|
|
3608
3926
|
else if (isResult && !this.claude.mock) {
|
|
3609
3927
|
// A {perMtok} model prices from tokens alone, so a result with no usage is
|
|
@@ -3629,6 +3947,12 @@ export class RunHarness extends EventEmitter {
|
|
|
3629
3947
|
} catch { /* derived state — never fail the run over it */ }
|
|
3630
3948
|
}
|
|
3631
3949
|
|
|
3950
|
+
// Agent memory (memory-write-split design §4): pair every Write/Edit aimed at a memory directory
|
|
3951
|
+
// with its tool_result, main stream and sub-agent frames alike (same cwd, same dirs), so a write
|
|
3952
|
+
// the CLI refused — or that failed for any other reason — is reported at sync time instead of
|
|
3953
|
+
// vanishing. Never throws; never mutates run state.
|
|
3954
|
+
this._trackMemoryWrites(e.raw, attr);
|
|
3955
|
+
|
|
3632
3956
|
// Sub-agent attribution. A child (Task/Agent) event carries parent_tool_use_id
|
|
3633
3957
|
// = the id of the parent's Task tool_use block; main-agent events carry null/
|
|
3634
3958
|
// absent. parent_tool_use_id is a TOP-LEVEL stream-json field; the message-
|
|
@@ -4004,6 +4328,30 @@ export class RunHarness extends EventEmitter {
|
|
|
4004
4328
|
* carries the figure.
|
|
4005
4329
|
* @param {number} costUsd
|
|
4006
4330
|
*/
|
|
4331
|
+
/**
|
|
4332
|
+
* Model bridge (model-bridge-design.md §7.2/§8.6): the premium-request-
|
|
4333
|
+
* initiating calls a node made through the bridge, read off the bridge's
|
|
4334
|
+
* per-execution counter when the node's terminal `result` arrives and
|
|
4335
|
+
* stamped on the step (`bridgeCalls`; `bridgeContinued` the tool-loop
|
|
4336
|
+
* continuations). Nothing for a non-bridged node, so the step shape is
|
|
4337
|
+
* unchanged there. Persisted through exec_meta (artifacts.mjs).
|
|
4338
|
+
*/
|
|
4339
|
+
_recordBridgeCalls(stepKey, executionId) {
|
|
4340
|
+
if (!executionId) return;
|
|
4341
|
+
const calls = bridgeCallsFor(executionId);
|
|
4342
|
+
if (!calls.initiated && !calls.continued) return;
|
|
4343
|
+
forgetBridgeTag(executionId);
|
|
4344
|
+
const key = stepKey
|
|
4345
|
+
|| (this.state.cycle ? `${this.state.phase}#${this.state.cycle}` : this.state.phase);
|
|
4346
|
+
const step = this.state.steps.find((s) => s.key === key);
|
|
4347
|
+
if (!step) return;
|
|
4348
|
+
step.bridgeCalls = (step.bridgeCalls || 0) + calls.initiated;
|
|
4349
|
+
step.bridgeContinued = (step.bridgeContinued || 0) + calls.continued;
|
|
4350
|
+
this.state.updatedAt = new Date().toISOString();
|
|
4351
|
+
this._emit('state', this.getState());
|
|
4352
|
+
this._persist().catch(() => {});
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4007
4355
|
_recordCost(costUsd, stepKey = null) {
|
|
4008
4356
|
if (!Number.isFinite(costUsd) || costUsd < 0) return;
|
|
4009
4357
|
const key = stepKey
|
|
@@ -4133,6 +4481,7 @@ export class RunHarness extends EventEmitter {
|
|
|
4133
4481
|
const rp = this.state.resumePoint;
|
|
4134
4482
|
// ABOVE the `if (rp …)` — a pause counts whether or not the engine produced a resume point.
|
|
4135
4483
|
this._metricsIv.pauses += 1;
|
|
4484
|
+
this._metricsIv.pausedAt = new Date().toISOString(); // resume() measures the parked time from here
|
|
4136
4485
|
this._metricsIv.lastPauseReason = this.pauseReason || null;
|
|
4137
4486
|
// _setPauseReason (run-harness.mjs:820) always stores a string or null.
|
|
4138
4487
|
this._metricsIv.lastPauseDetail = this.pauseDetail == null ? null : String(this.pauseDetail).slice(0, 400);
|