software-defence-factory 0.15.3 → 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/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.
@@ -27,16 +27,18 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
27
27
  // 0.15.3 isolates local Pi mutable state and denies project resource trust.
28
28
  // Evidence schemas, candidate reconstruction and frozen profile guards are unchanged;
29
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.
30
32
  // Older writers still require exact patch/tree reconstruction before publication.
31
33
  // Deliberately independent of VERSION: a release bump is not
32
34
  // evidence compatibility. Re-audit this list for every trust-relevant writer,
33
35
  // isolation or validation change; remove versions whose guarantees no longer
34
36
  // satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
35
- 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']);
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']);
36
38
 
37
39
  export function isSupportedExecutionProfile(profile) {
38
- if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
39
- : ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3'].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')
40
42
  && profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
41
43
  && (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
42
44
  && /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
@@ -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 = execution.executor === 'codex' ? new CodexUsageParser() : null;
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) {
@@ -127,7 +130,17 @@ async function container(mode, input, command, options = {}) {
127
130
  console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: harnessOf(config) === 'mock' }));
128
131
  code = await new Promise((ok, fail) => {
129
132
  const child = spawn('docker', args, { stdio: ['pipe','pipe','pipe'] });
130
- child.stdout.on('data', bytes => { log.write('stdout', bytes); usageParser?.write(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
+ });
131
144
  child.stderr.on('data', bytes => log.write('stderr', bytes));
132
145
  child.stdin.on('error',error => { if (error.code !== 'EPIPE') fail(error); });
133
146
  child.on('error',fail); child.on('close',(code,signal) => { exitSignal=signal; ok(code); }); child.stdin.end(input);
@@ -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
+ }
@@ -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 = retainedCodexUsage(state, job, attempt);
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 = retainedCodexUsage(state, job, attempt);
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')); }