software-defence-factory 0.15.2 → 0.15.4
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/docs/setup.md +31 -0
- package/docs/usage.md +57 -0
- package/factory/execution-profile.mjs +8 -3
- package/factory/executor.mjs +16 -4
- package/factory/pi-context-extension.mjs +1 -2
- package/factory/pi-local-launch.mjs +15 -7
- package/factory/pi-usage.mjs +106 -0
- package/factory/processes.mjs +27 -2
- package/factory/ui/assets/{index-BqZ4LAbp.js → index-DbTPjlnO.js} +2 -2
- package/factory/ui/index.html +1 -1
- package/factory/usage.mjs +33 -21
- package/package.json +2 -2
package/docs/setup.md
CHANGED
|
@@ -432,6 +432,37 @@ local inference does not mean zero electricity, hardware or operator cost. Only
|
|
|
432
432
|
then qualify a suitable real Factory issue; no customer jobs are part of this slice.
|
|
433
433
|
|
|
434
434
|
|
|
435
|
+
## Local Pi project resources (0.15.3, #103)
|
|
436
|
+
|
|
437
|
+
The local launcher gives each invocation a private `0700` directory under the
|
|
438
|
+
job's existing `/tmp` tmpfs for Pi runtime stores/locks. `PI_CODING_AGENT_DIR`
|
|
439
|
+
points there; it is never the read-only `/factory-local` binding mount. The
|
|
440
|
+
selected `models.json` is linked to that mount, and the launcher/context adapter,
|
|
441
|
+
policy and six Factory skills remain read-only. No host home, saved trust,
|
|
442
|
+
authentication or settings are copied. Normal exit removes this state; container
|
|
443
|
+
teardown removes interrupted state. Build and Review never share it. The root
|
|
444
|
+
filesystem and Review checkout remain read-only.
|
|
445
|
+
|
|
446
|
+
Pinned Pi 0.87.1 consults project trust even for an empty `.agents/skills`
|
|
447
|
+
directory. Factory explicitly passes `--no-approve` to deny project settings,
|
|
448
|
+
packages and executable resource loading, while retaining `--no-extensions`,
|
|
449
|
+
`--no-skills` and `--no-prompt-templates`. A writable Pi home does not authorize
|
|
450
|
+
project dependency installation. Only the explicit bundled context extension
|
|
451
|
+
and `--skill /factory-skills` catalog are supported; their loading and the
|
|
452
|
+
context-ready handshake are tested against actual Pi. Task stdin and ordinary
|
|
453
|
+
`AGENTS.md` context remain available as untrusted text. Repository Pi settings,
|
|
454
|
+
extensions/packages and automatically discovered repository skills are not a
|
|
455
|
+
supported configuration path. Explicit task-driven file reads are still possible
|
|
456
|
+
within the sandbox and grant no additional authority.
|
|
457
|
+
|
|
458
|
+
Before accepting this repair, the lead must qualify the exact installed 0.15.3
|
|
459
|
+
package/image through CLI/API, representative native local Build and separate
|
|
460
|
+
read-only Review, and one actual local-model run. `npm run qualify:pi` uses actual
|
|
461
|
+
Pi with **synthetic HTTP inference**, not model judgment. See [trust-store
|
|
462
|
+
proof and limits](proof.md#pi-project-trust-startup-0153-103). Retain the failed
|
|
463
|
+
#51 attempt unchanged and unaccepted; retry only through a newly authorized
|
|
464
|
+
attempt after qualification. Do not modify private installations from a job.
|
|
465
|
+
|
|
435
466
|
## Local context budget and worker image (0.15.2, #100)
|
|
436
467
|
|
|
437
468
|
The standard worker recipe pins **`@earendil-works/pi-coding-agent@0.87.1`** from
|
package/docs/usage.md
CHANGED
|
@@ -46,3 +46,60 @@ Historical recovery is optional and budgeted to eight lookups per second and
|
|
|
46
46
|
512 cached terminal attempts per controller lifetime. Further attempts remain
|
|
47
47
|
unknown; new persisted measurements bypass these legacy read limits. This
|
|
48
48
|
prevents large old queues from rereading all logs on every status poll.
|
|
49
|
+
|
|
50
|
+
## Pi stdout measurements (0.15.4)
|
|
51
|
+
|
|
52
|
+
New Pi attempts use `source: "pi_jsonl"`. Factory collects stdout before bounded
|
|
53
|
+
log truncation; stderr, tool results and streaming updates cannot supply usage.
|
|
54
|
+
The audited official `@earendil-works/pi-coding-agent` **0.87.1** OpenAI
|
|
55
|
+
completions adapter reports uncached `input`, `cacheRead`, `cacheWrite`, `output`
|
|
56
|
+
and their sum in `totalTokens`. Factory maps them to the existing contract:
|
|
57
|
+
|
|
58
|
+
- `input_tokens = input + cacheRead + cacheWrite` (inclusive input).
|
|
59
|
+
- `cached_input_tokens = cacheRead`, and `cache_write_input_tokens = cacheWrite`.
|
|
60
|
+
- `token_usage = input_tokens + output_tokens`; neither cache subset is added again.
|
|
61
|
+
|
|
62
|
+
Cache-write attribution is optional for historical/Codex records, not assumed
|
|
63
|
+
zero when absent. Pi requires all five counters, validates their sum, and
|
|
64
|
+
preserves exact decimal strings. Unsafe numeric values are rejected. An all-zero
|
|
65
|
+
Pi usage object remains unknown: the installed provider also emits this initial
|
|
66
|
+
placeholder when no usage arrived, including failed requests. Provider cost
|
|
67
|
+
fields and configured zero prices do not establish monetary consumption; local
|
|
68
|
+
hardware/electricity and unpriced provider cost remain unknown.
|
|
69
|
+
|
|
70
|
+
The pinned agent loop emits completed `message_end` assistant messages and
|
|
71
|
+
repeats them, in order, in that agent turn's `agent_end.messages`. Factory
|
|
72
|
+
reconciles positions within each `agent_start`/`agent_end` interval. Equal token
|
|
73
|
+
counts and equal timestamps are not unique IDs: distinct positions and explicit
|
|
74
|
+
new agent turns count separately. Repeated snapshots do not add tokens; a final
|
|
75
|
+
aggregate alone can supply counts. Contradictory or ambiguous snapshots never
|
|
76
|
+
add unmatched counts. Missing, malformed and oversized evidence cannot turn an
|
|
77
|
+
observation into complete coverage or a measured zero.
|
|
78
|
+
|
|
79
|
+
All Pi observations currently have **partial** coverage, even after a successful
|
|
80
|
+
run. Pi 0.87.1 performs compaction summary calls outside the visible assistant
|
|
81
|
+
aggregate and can retry provider requests without independent failed-request
|
|
82
|
+
usage accounting. A recovered job can succeed while these totals remain
|
|
83
|
+
incomplete. Absence of a retry/compaction event is not proof of full coverage.
|
|
84
|
+
No scorer, cost estimate, quality claim or acceptance authority follows from
|
|
85
|
+
these measurements.
|
|
86
|
+
|
|
87
|
+
Parsing is bounded to an 8 MiB line buffer, 262,144 framed lines, 2,048 observed
|
|
88
|
+
assistant messages and at most 1 MiB of retained counter signatures per turn.
|
|
89
|
+
Message content is discarded after each line. Counts and aggregate totals are
|
|
90
|
+
bounded to 128 decimal digits. Beyond a limit, already observed totals remain
|
|
91
|
+
partial; with no usable evidence the result is unknown.
|
|
92
|
+
|
|
93
|
+
A private, atomic `jobs/JOB/RUN/usage.json` checkpoint contains only usage and
|
|
94
|
+
job/run/phase/frozen execution identity. It is controller-owned, outside the
|
|
95
|
+
worker output mount, and preserves observations when cancellation kills the
|
|
96
|
+
executor before its final result. Normal completion stores the same normalized
|
|
97
|
+
usage in SQLite and measurement artifacts. CLI `status`, the status API and
|
|
98
|
+
existing Analytics/issue detail consume that shared contract after restart.
|
|
99
|
+
Model/provider/role attribution still comes from the frozen execution profile,
|
|
100
|
+
never from usage event fields. Failed/cancelled runs keep their outcome.
|
|
101
|
+
|
|
102
|
+
There is **no Pi historical log backfill**. Existing records and mixed or
|
|
103
|
+
truncated legacy logs are not rewritten. This bounded measurement slice leaves
|
|
104
|
+
#51's scoring/benchmark engine, #69's model qualification and #70's improvement
|
|
105
|
+
proposal workflow open.
|
|
@@ -24,16 +24,21 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
|
|
|
24
24
|
// 0.15.1 changes release downloads only; v1/v2/v3 writers and policy are unchanged.
|
|
25
25
|
// 0.15.2 updates the opt-in local launcher and standard image; schemas, frozen
|
|
26
26
|
// command/config/image hashes, protected checks and acceptance guards are unchanged.
|
|
27
|
+
// 0.15.3 isolates local Pi mutable state and denies project resource trust.
|
|
28
|
+
// Evidence schemas, candidate reconstruction and frozen profile guards are unchanged;
|
|
29
|
+
// compatibility does not qualify an old local startup or accept a failed attempt.
|
|
30
|
+
// 0.15.4 adds Pi usage metadata only; protected evidence schemas, frozen
|
|
31
|
+
// execution selection, isolation and acceptance policy remain unchanged.
|
|
27
32
|
// Older writers still require exact patch/tree reconstruction before publication.
|
|
28
33
|
// Deliberately independent of VERSION: a release bump is not
|
|
29
34
|
// evidence compatibility. Re-audit this list for every trust-relevant writer,
|
|
30
35
|
// isolation or validation change; remove versions whose guarantees no longer
|
|
31
36
|
// satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
|
|
32
|
-
const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2']);
|
|
37
|
+
const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4']);
|
|
33
38
|
|
|
34
39
|
export function isSupportedExecutionProfile(profile) {
|
|
35
|
-
if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
|
|
36
|
-
: ['0.14.0', '0.15.0', '0.15.1', '0.15.2'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
|
|
40
|
+
if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
|
|
41
|
+
: ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
|
|
37
42
|
&& profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
|
|
38
43
|
&& (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
|
|
39
44
|
&& /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
|
package/factory/executor.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { ROOT, run, save, json, digest, instanceLabel, stopContainers } from './
|
|
|
9
9
|
import { incidentFor, validateReport } from './incident.mjs';
|
|
10
10
|
import { BoundedLog } from './bounded-log.mjs';
|
|
11
11
|
import { CodexUsageParser, emptyUsage, usageFields } from './usage.mjs';
|
|
12
|
+
import { PiUsageParser } from './pi-usage.mjs';
|
|
12
13
|
import { assertRetainedSource, publicSourceAdmission, publicContinuation, restoreBuildCheckout } from './source-admission.mjs';
|
|
13
14
|
import { runCandidateGit, runCandidateGitRaw } from './git-environment.mjs';
|
|
14
15
|
import { selectedInferenceSecrets, writeSelectedModelEnvironment } from './model-environment.mjs';
|
|
@@ -103,7 +104,9 @@ async function container(mode, input, command, options = {}) {
|
|
|
103
104
|
if (scratch) args.push('--mount',`type=bind,source=${scratch},target=/scratch`);
|
|
104
105
|
if (mode === 'verify') args.push('--env',`FACTORY_BASE_REVISION=${git('rev-parse',`${metadata().base}^{commit}`)}`);
|
|
105
106
|
const logPath = join(folder, attempt, `${mode}.log`);
|
|
106
|
-
const log = new BoundedLog(), usageParser =
|
|
107
|
+
const log = new BoundedLog(), usageParser = mode === 'verify' ? null
|
|
108
|
+
: execution.executor === 'codex' ? new CodexUsageParser() : execution.executor === 'pi' ? new PiUsageParser() : null;
|
|
109
|
+
let savedObservations = 0;
|
|
107
110
|
let exitSignal, selectedModelEnvironment = false, inferenceSecrets = [], code, cleanupError, reportError;
|
|
108
111
|
try {
|
|
109
112
|
if (localDirectory) {
|
|
@@ -113,8 +116,7 @@ async function container(mode, input, command, options = {}) {
|
|
|
113
116
|
for (const file of ['pi-context-extension.mjs', 'pi-context-budget.mjs'])
|
|
114
117
|
writeFileSync(join(localDirectory, file), readFileSync(join(ROOT, 'factory', file)), { mode: 0o600, flag: 'wx' });
|
|
115
118
|
writeFileSync(join(localDirectory, 'models.json'), JSON.stringify(localRegistry(binding)), { mode: 0o600, flag: 'wx' });
|
|
116
|
-
args.push('--mount', `type=bind,source=${localDirectory},target=${LOCAL_AGENT_DIR},readonly
|
|
117
|
-
'--env', `PI_CODING_AGENT_DIR=${LOCAL_AGENT_DIR}`);
|
|
119
|
+
args.push('--mount', `type=bind,source=${localDirectory},target=${LOCAL_AGENT_DIR},readonly`);
|
|
118
120
|
}
|
|
119
121
|
selectedModelEnvironment = writeSelectedModelEnvironment(join(state, 'model.env'), modelEnvironmentPath, {
|
|
120
122
|
phase: mode, executor: execution.executor, inferenceProvider: config.inferenceProvider,
|
|
@@ -128,7 +130,17 @@ async function container(mode, input, command, options = {}) {
|
|
|
128
130
|
console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: harnessOf(config) === 'mock' }));
|
|
129
131
|
code = await new Promise((ok, fail) => {
|
|
130
132
|
const child = spawn('docker', args, { stdio: ['pipe','pipe','pipe'] });
|
|
131
|
-
child.stdout.on('data', bytes => {
|
|
133
|
+
child.stdout.on('data', bytes => {
|
|
134
|
+
usageParser?.write(bytes);
|
|
135
|
+
// Controller-owned metadata, outside the worker's /output mount. Atomic
|
|
136
|
+
// checkpoints survive cancellation without consulting mixed log tails.
|
|
137
|
+
if (usageParser instanceof PiUsageParser && usageParser.observed !== savedObservations) {
|
|
138
|
+
observedUsage = usageParser.snapshot();
|
|
139
|
+
save(join(folder, attempt, 'usage.json'), { version: 1, job, attempt, phase, execution, usage: observedUsage });
|
|
140
|
+
savedObservations = usageParser.observed;
|
|
141
|
+
}
|
|
142
|
+
log.write('stdout', bytes);
|
|
143
|
+
});
|
|
132
144
|
child.stderr.on('data', bytes => log.write('stderr', bytes));
|
|
133
145
|
child.stdin.on('error',error => { if (error.code !== 'EPIPE') fail(error); });
|
|
134
146
|
child.on('error',fail); child.on('close',(code,signal) => { exitSignal=signal; ok(code); }); child.stdin.end(input);
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
// Explicitly mounted Factory adapter, never discovered from the host/repository.
|
|
2
2
|
import { readFileSync, writeSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
3
|
import { openAICompletionsApi } from '@earendil-works/pi-ai';
|
|
5
4
|
import { bytes, fitToolText, projectedTokens, restoreOutputBudget, validatePairs } from './pi-context-budget.mjs';
|
|
6
5
|
|
|
7
6
|
export default function factoryLocalContext(pi) {
|
|
8
|
-
const registry = JSON.parse(readFileSync(
|
|
7
|
+
const registry = JSON.parse(readFileSync(new URL('./models.json', import.meta.url), 'utf8'));
|
|
9
8
|
const configured = registry.providers['factory-local'].models[0];
|
|
10
9
|
const window = configured.contextWindow, output = configured.maxTokens;
|
|
11
10
|
let task;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Mounted read-only beside the single selected registry. Keep all JSON evidence;
|
|
2
2
|
// process exit alone does not establish a successfully completed Pi turn.
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { spawn, spawnSync } from 'node:child_process';
|
|
@@ -116,16 +116,24 @@ function launch() {
|
|
|
116
116
|
// Never inherit credential inputs, provider overrides, NODE_OPTIONS or external
|
|
117
117
|
// Pi directories. The runtime image supplies binaries; the repository supplies data.
|
|
118
118
|
const env = Object.fromEntries(['PATH', 'HOME', 'LANG', 'TMPDIR', 'FACTORY_PHASE'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
|
|
119
|
-
env.PI_CODING_AGENT_DIR = directory;
|
|
120
119
|
for (const file of ['pi-context-extension.mjs', 'pi-context-budget.mjs']) readFileSync(join(directory, file));
|
|
121
120
|
const version = spawnSync('pi', ['--version'], { env, encoding: 'utf8', timeout: 10000 });
|
|
122
121
|
if (version.status !== 0 || version.stdout.trim() !== '0.87.1')
|
|
123
122
|
throw new Error('Local context protection requires pinned Pi 0.87.1; qualify and select the worker image while idle. No fallback.');
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
const
|
|
123
|
+
// Pi's home is mutable runtime state (including trust-store locks), not the
|
|
124
|
+
// operator-owned binding. Each launch gets a private directory in the existing
|
|
125
|
+
// sandbox tmpfs; no host trust/auth/settings are copied. Keep model input linked
|
|
126
|
+
// to its read-only mount, and load the adapter directly from that mount.
|
|
127
|
+
const runtimeDirectory = mkdtempSync('/tmp/factory-pi-');
|
|
128
|
+
const cleanup = () => rmSync(runtimeDirectory, { recursive: true, force: true });
|
|
129
|
+
process.on('exit', cleanup); // Container teardown also clears interrupted runs.
|
|
130
|
+
symlinkSync(join(directory, 'models.json'), join(runtimeDirectory, 'models.json'));
|
|
131
|
+
env.PI_CODING_AGENT_DIR = runtimeDirectory;
|
|
132
|
+
// Denying project trust also prevents project settings/packages and dependency
|
|
133
|
+
// installation, which --no-extensions alone does not prevent. Explicit CLI
|
|
134
|
+
// resources remain available in pinned Pi: only our adapter and mounted skills.
|
|
135
|
+
const child = spawn('pi', [...args, '--no-approve', '--no-extensions', '--no-skills', '--no-prompt-templates',
|
|
136
|
+
'--extension', join(directory, 'pi-context-extension.mjs'), '--api-key', 'factory-local-keyless'], { env, stdio: ['inherit', 'pipe', 'inherit'] });
|
|
129
137
|
const completion = new PiCompletion();
|
|
130
138
|
child.stdout.setEncoding('utf8');
|
|
131
139
|
child.stdout.on('data', chunk => {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { UsageLineParser, tokenCount, MAX_USAGE_EVENTS, MAX_USAGE_COUNT_DIGITS } from './usage.mjs';
|
|
2
|
+
|
|
3
|
+
// Pi's final aggregate includes message content and can be much larger than a
|
|
4
|
+
// Codex usage line. Discard content after parsing; retain only bounded counters.
|
|
5
|
+
export const MAX_PI_USAGE_LINE_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
export const MAX_PI_USAGE_METADATA_BYTES = 1024 * 1024;
|
|
7
|
+
export const MAX_PI_USAGE_EVENTS = 262144;
|
|
8
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
|
|
10
|
+
function counts(message) {
|
|
11
|
+
if (!object(message.usage) || !['stop', 'toolUse', 'length', 'error', 'aborted'].includes(message.stopReason)) return null;
|
|
12
|
+
try {
|
|
13
|
+
const { input, output, cacheRead, cacheWrite, totalTokens } = message.usage;
|
|
14
|
+
const uncached = tokenCount(input), out = tokenCount(output), read = tokenCount(cacheRead), write = tokenCount(cacheWrite);
|
|
15
|
+
const inclusive = uncached + read + write;
|
|
16
|
+
if (inclusive + out !== tokenCount(totalTokens)) return null;
|
|
17
|
+
if (Object.hasOwn(message.usage, 'reasoning') && tokenCount(message.usage.reasoning) > out) return null;
|
|
18
|
+
// The provider initializes missing/failed-request usage to zero. Even a
|
|
19
|
+
// successful stop with all zeros does not establish a measured zero call.
|
|
20
|
+
if (inclusive + out === 0n) return null;
|
|
21
|
+
return [inclusive, out, read, write].map(value => value.toString());
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PiUsageParser extends UsageLineParser {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(MAX_PI_USAGE_LINE_BYTES);
|
|
28
|
+
this.totals = [0n, 0n, 0n, 0n];
|
|
29
|
+
this.observed = 0;
|
|
30
|
+
this.events = 0;
|
|
31
|
+
this.metadataBytes = 0;
|
|
32
|
+
this.messages = [];
|
|
33
|
+
this.ended = false;
|
|
34
|
+
this.ambiguous = false;
|
|
35
|
+
this.exhausted = false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
lineComplete() {
|
|
39
|
+
if (!this.exhausted && ++this.events > MAX_PI_USAGE_EVENTS) this.exhausted = true;
|
|
40
|
+
super.lineComplete();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
parseLine() {
|
|
44
|
+
if (!this.length || this.exhausted) return;
|
|
45
|
+
let event;
|
|
46
|
+
try { event = JSON.parse(this.line.toString('utf8', 0, this.length)); }
|
|
47
|
+
catch { this.ambiguous = true; return; }
|
|
48
|
+
if (!object(event)) return;
|
|
49
|
+
// The pinned agent loop's newMessages starts afresh at each agent_start.
|
|
50
|
+
// Positions within that turn, not timestamps or equal token counts, match
|
|
51
|
+
// message_end to the repeated agent_end snapshot. New turns may be identical.
|
|
52
|
+
if (event.type === 'agent_start') {
|
|
53
|
+
this.messages = []; this.metadataBytes = 0;
|
|
54
|
+
this.ended = false; this.ambiguous = false;
|
|
55
|
+
} else if (event.type === 'message_end') {
|
|
56
|
+
if (!object(event.message)) { this.ambiguous = true; return; }
|
|
57
|
+
if (event.message.role !== 'assistant') return;
|
|
58
|
+
if (this.ended) { this.ambiguous = true; return; }
|
|
59
|
+
this.append(counts(event.message));
|
|
60
|
+
} else if (event.type === 'agent_end') {
|
|
61
|
+
if (!Array.isArray(event.messages) || event.messages.length > MAX_USAGE_EVENTS) { this.ambiguous = true; return; }
|
|
62
|
+
let index = 0;
|
|
63
|
+
for (const message of event.messages) {
|
|
64
|
+
if (!object(message)) { this.ambiguous = true; break; }
|
|
65
|
+
if (message.role !== 'assistant') continue;
|
|
66
|
+
const value = counts(message), signature = JSON.stringify(value);
|
|
67
|
+
if (index < this.messages.length) {
|
|
68
|
+
if (signature !== this.messages[index]) this.ambiguous = true;
|
|
69
|
+
} else if (!this.ended && !this.ambiguous && !this.incomplete) this.append(value);
|
|
70
|
+
else this.ambiguous = true;
|
|
71
|
+
index++;
|
|
72
|
+
}
|
|
73
|
+
if (index !== this.messages.length) this.ambiguous = true;
|
|
74
|
+
this.ended = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
append(value) {
|
|
79
|
+
if (this.exhausted) return;
|
|
80
|
+
const signature = JSON.stringify(value);
|
|
81
|
+
if (this.observed >= MAX_USAGE_EVENTS || this.messages.length >= MAX_USAGE_EVENTS || this.metadataBytes + signature.length > MAX_PI_USAGE_METADATA_BYTES) {
|
|
82
|
+
this.exhausted = true; return;
|
|
83
|
+
}
|
|
84
|
+
this.messages.push(signature); this.metadataBytes += signature.length;
|
|
85
|
+
if (!value) return;
|
|
86
|
+
const next = this.totals.map((total, index) => total + BigInt(value[index]));
|
|
87
|
+
// Keep aggregate counts and their shared total representable on readback.
|
|
88
|
+
if ([...next, next[0] + next[1]].some(total => total.toString().length > MAX_USAGE_COUNT_DIGITS)) {
|
|
89
|
+
this.exhausted = true; return;
|
|
90
|
+
}
|
|
91
|
+
this.totals = next; this.observed++;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
snapshot() {
|
|
95
|
+
if (!this.observed) return null;
|
|
96
|
+
const [input, output, cached, writes] = this.totals.map(String);
|
|
97
|
+
return { input_tokens: input, output_tokens: output, cached_input_tokens: cached,
|
|
98
|
+
cache_write_input_tokens: writes, source: 'pi_jsonl', coverage: 'partial' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
finish() {
|
|
102
|
+
// JSONL requires a newline. A trailing fragment is not a completed event.
|
|
103
|
+
if (this.length || this.overflow) this.incomplete = true;
|
|
104
|
+
return this.snapshot();
|
|
105
|
+
}
|
|
106
|
+
}
|
package/factory/processes.mjs
CHANGED
|
@@ -84,10 +84,35 @@ export function retainedCodexUsage(state, job, attempt) {
|
|
|
84
84
|
} catch { return null; }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// New Pi measurements are controller-owned metadata, never historical log
|
|
88
|
+
// backfill. A stopped executor may have checkpointed before producing a result.
|
|
89
|
+
export function retainedPiUsage(state, job, attempt) {
|
|
90
|
+
try {
|
|
91
|
+
if (!/^job_[a-z0-9]+$/.test(job?.id || '') || !/^run_[a-z0-9]+$/.test(attempt?.id || '')
|
|
92
|
+
|| !['build', 'review', 'defence'].includes(attempt.command) || attempt.execution?.executor !== 'pi') return null;
|
|
93
|
+
const folder = join(state, 'jobs', job.id), runFolder = join(folder, attempt.id);
|
|
94
|
+
for (const path of [join(state, 'jobs'), folder, runFolder]) {
|
|
95
|
+
const stat = lstatSync(path);
|
|
96
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) return null;
|
|
97
|
+
}
|
|
98
|
+
const path = join(runFolder, 'usage.json'), stat = lstatSync(path);
|
|
99
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) || stat.size > 16384) return null;
|
|
100
|
+
const saved = JSON.parse(readFileSync(path, 'utf8'));
|
|
101
|
+
if (saved.version !== 1 || saved.job !== job.id || saved.attempt !== attempt.id || saved.phase !== attempt.command
|
|
102
|
+
|| !isDeepStrictEqual(saved.execution, attempt.execution) || saved.usage?.source !== 'pi_jsonl') return null;
|
|
103
|
+
const normalized = usageFields(saved.usage, attempt.execution, attempt.command).usage;
|
|
104
|
+
return normalized.status ? null : normalized;
|
|
105
|
+
} catch { return null; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function retainedUsage(state, job, attempt) {
|
|
109
|
+
return retainedPiUsage(state, job, attempt) || retainedCodexUsage(state, job, attempt);
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
function usageForAttempt(state, job, attempt) {
|
|
88
113
|
const stored = usageFields(attempt.usage, attempt.execution, attempt.command);
|
|
89
114
|
if (stored.usage.status !== 'unknown') return stored;
|
|
90
|
-
const recovered =
|
|
115
|
+
const recovered = retainedUsage(state, job, attempt);
|
|
91
116
|
return recovered ? usageFields(recovered, attempt.execution, attempt.command) : stored;
|
|
92
117
|
}
|
|
93
118
|
|
|
@@ -186,7 +211,7 @@ export function executors(state, recovery = {}) {
|
|
|
186
211
|
try { exit = await done; }
|
|
187
212
|
finally { clearTimeout(deadline); children.delete(job.id); }
|
|
188
213
|
if (exit.error) throw exit.error;
|
|
189
|
-
const recovered =
|
|
214
|
+
const recovered = retainedUsage(state, job, attempt);
|
|
190
215
|
if (!existsSync(resultPath)) return { outcome: 'blocked', ...usageFields(recovered || emptyUsage(attempt.execution, attempt.command), attempt.execution, attempt.command), summary: `Executor exited ${exit.code} without a result` };
|
|
191
216
|
let outcome;
|
|
192
217
|
try { outcome = JSON.parse(readFileSync(resultPath, 'utf8')); }
|