software-defence-factory 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/npm.md +10 -0
- package/docs/recovery.md +16 -0
- package/docs/setup.md +34 -0
- package/docs/usage.md +57 -0
- package/factory/execution-profile.mjs +7 -3
- package/factory/executor.mjs +15 -2
- package/factory/pi-context-budget.mjs +30 -1
- package/factory/pi-context-extension.mjs +103 -35
- package/factory/pi-local-launch.mjs +14 -1
- 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/npm.md
CHANGED
|
@@ -282,3 +282,13 @@ reject tampered selections/images/policies and unsupported future writers, and
|
|
|
282
282
|
verify that readback does not rewrite retained evidence. Image adoption is a
|
|
283
283
|
separate idle operator action; existing attempt images are not migrated by a CLI
|
|
284
284
|
update. See [the migration/rollback recipe](setup.md#local-context-budget-and-worker-image-0152-100).
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
The 0.15.5 compatibility audit adds that exact writer to the explicit v1/v2/v3
|
|
288
|
+
allowlists. The examined change is confined to local Pi projection, proactive
|
|
289
|
+
recovery and completion validation. Evidence schemas, frozen commands, model and
|
|
290
|
+
image selections, policy hashes, candidate reconstruction and acceptance guards
|
|
291
|
+
remain unchanged from 0.15.4. Profile tests exercise all phases and inherited,
|
|
292
|
+
cloud, hybrid and local definitions, reject changed selections and future writers,
|
|
293
|
+
and verify immutable readback. This does not upgrade or qualify historical local
|
|
294
|
+
attempts; installed image adoption and actual inference remain separate gates.
|
package/docs/recovery.md
CHANGED
|
@@ -44,6 +44,22 @@ retry around a retained writer or reuse earlier story evidence for a changed
|
|
|
44
44
|
candidate or policy. Browser traces and screenshots are private evidence and
|
|
45
45
|
are presented through the shared interfaces. See [the web contract](web-verification.md).
|
|
46
46
|
|
|
47
|
+
## Local Pi history exhaustion
|
|
48
|
+
|
|
49
|
+
The 0.15.5 candidate adds bounded proactive recovery for short histories, including
|
|
50
|
+
productive writes whose immutable arguments require fewer retained groups;
|
|
51
|
+
see [limits and image adoption](setup.md#local-context-budget-and-worker-image-0152-100).
|
|
52
|
+
A failed/empty summary, exhausted recovery limit, cancellation or irreducible
|
|
53
|
+
request remains a failed attempt even if a tool already wrote a report. Keep its
|
|
54
|
+
checkout, raw events and private diagnostic evidence. Reconcile the stopped
|
|
55
|
+
worker before admitting a smaller task or explicitly selecting another qualified
|
|
56
|
+
profile as a separate attempt. Do not enlarge the allocation, rewrite a failed
|
|
57
|
+
history or label an explicit cloud retry as local success. Successful transport
|
|
58
|
+
recovery still needs semantic checks and independent Review. The reviewed first
|
|
59
|
+
checkpoint failed real local qualification; the revised artifact needs a new
|
|
60
|
+
lead-owned installed and real-task pass before acceptance. See the
|
|
61
|
+
[rejected trial and source regression](proof.md#short-local-pi-history-recovery-0155-candidate-105).
|
|
62
|
+
|
|
47
63
|
## Review feedback or phase retry
|
|
48
64
|
|
|
49
65
|
Use **Request changes** on a failed software review only when its validated
|
package/docs/setup.md
CHANGED
|
@@ -509,6 +509,40 @@ guessed or silently rewritten. Do not add project Pi settings to bypass
|
|
|
509
509
|
the admitted binding or rely on custom tuning that has not been qualified.
|
|
510
510
|
See [pinned compaction behavior](https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/docs/compaction.md).
|
|
511
511
|
|
|
512
|
+
Starting with the **0.15.5 candidate (#105)**, the local adapter also checks
|
|
513
|
+
history pressure at Pi's completed-tool-turn boundary. This conservative byte
|
|
514
|
+
signal counts the system/task/tool envelope once, call arguments, non-tool text
|
|
515
|
+
and capped tool excerpts, separately from provider-reported usage. At 65% of the
|
|
516
|
+
input budget it considers Pi's supported summary generator and append-only
|
|
517
|
+
compaction entry. The newest complete tool group is retained; up to three older
|
|
518
|
+
complete groups can remain as additional evidence when the actual summary and
|
|
519
|
+
retained envelope fit. The original task is restored exactly on every generation;
|
|
520
|
+
raw events and existing session entries are not rewritten. Large-result bursts
|
|
521
|
+
still use upstream compaction and the final request projection. Projection gives
|
|
522
|
+
recent short results (up to 2 KiB each, 8 KiB total and at most a quarter of the
|
|
523
|
+
input budget) priority when they fit.
|
|
524
|
+
|
|
525
|
+
Recovery allows at most 16 proactive summaries per process and two retries per
|
|
526
|
+
summary. It must reduce pressure by at least 4 KiB and leave headroom of 8 KiB
|
|
527
|
+
(or 15% of the input budget when smaller). Its preferred reduction target adapts
|
|
528
|
+
to the immutable latest-group/envelope floor and actual summary size; it cannot
|
|
529
|
+
require that floor to fit an arbitrary fraction of the trigger. The next trigger
|
|
530
|
+
also leaves room for new work. If the latest write/group alone makes recovery
|
|
531
|
+
unproductive, the adapter defers to a later complete turn under the unchanged
|
|
532
|
+
wire guard, when that group can become summary input. This does not permit an
|
|
533
|
+
oversized request through transport. Empty, failed, cancelled or ineffective
|
|
534
|
+
summaries fail the attempt. Oversized instructions, arguments or retained groups
|
|
535
|
+
can still fail closed. These are bounded recovery limits, not automatic
|
|
536
|
+
context/output tuning or a guarantee of useful model work.
|
|
537
|
+
|
|
538
|
+
The selected context, generation output and model remain unchanged; summary
|
|
539
|
+
output is separately capped (6,553 for proactive summaries with 8,192 selected
|
|
540
|
+
output on pinned Pi). `factory_context_recovery` JSONL events record only
|
|
541
|
+
pressure, envelope/floor sizes, group counts and recovery progress, without
|
|
542
|
+
prompts/content. Pi token accounting stays partial; this summary path is not
|
|
543
|
+
added to ordinary assistant usage totals. See
|
|
544
|
+
[source measurements, rejected real trial and pending qualification](proof.md#short-local-pi-history-recovery-0155-candidate-105).
|
|
545
|
+
|
|
512
546
|
Before adoption, stop and reconcile all work, retain the old immutable image ID
|
|
513
547
|
and a stopped private-state backup. Build/qualify the exact candidate image, then
|
|
514
548
|
explicitly select it with `factory install --image LOCAL_IMAGE_REF --state PRIVATE_STATE`
|
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,20 @@ 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.
|
|
32
|
+
// 0.15.5 changes only local context recovery/projection; v1/v2/v3 schemas,
|
|
33
|
+
// frozen selection hashes, provenance, checks and acceptance guards are unchanged.
|
|
30
34
|
// Older writers still require exact patch/tree reconstruction before publication.
|
|
31
35
|
// Deliberately independent of VERSION: a release bump is not
|
|
32
36
|
// evidence compatibility. Re-audit this list for every trust-relevant writer,
|
|
33
37
|
// isolation or validation change; remove versions whose guarantees no longer
|
|
34
38
|
// 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']);
|
|
39
|
+
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', '0.15.5']);
|
|
36
40
|
|
|
37
41
|
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')
|
|
42
|
+
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', '0.15.5'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
|
|
43
|
+
: ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
|
|
40
44
|
&& profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
|
|
41
45
|
&& (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
|
|
42
46
|
&& /^[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) {
|
|
@@ -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 => {
|
|
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);
|
|
@@ -35,6 +35,16 @@ function excerpt(text, limit) {
|
|
|
35
35
|
return Buffer.byteLength(shortened) < Buffer.byteLength(text) ? shortened : text;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A pressure signal, not a token estimate: cap large tool bodies so upstream's
|
|
39
|
+
// existing large-result recovery still owns bursts, but count every call/argument
|
|
40
|
+
// and a useful short excerpt. Provider usage cannot measure this growing floor.
|
|
41
|
+
export function historyPressure(messages, envelope) {
|
|
42
|
+
const copy = structuredClone(messages);
|
|
43
|
+
for (const message of copy) if (message.role === 'toolResult')
|
|
44
|
+
for (const part of message.content || []) if (part.type === 'text') part.text = excerpt(part.text, 1024);
|
|
45
|
+
return envelope + bytes(copy) + 64 * copy.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
// Uniform cap shares space among large results without penalizing small ones.
|
|
39
49
|
// Calls, arguments, identities, ordering and all non-tool text are immutable.
|
|
40
50
|
export function fitToolText(value, budget, measure = bytes, native = false) {
|
|
@@ -49,8 +59,27 @@ export function fitToolText(value, budget, measure = bytes, native = false) {
|
|
|
49
59
|
for (const part of message.content) if (part.type === 'text') slots.push([part, 'text', part.text]);
|
|
50
60
|
}
|
|
51
61
|
}
|
|
52
|
-
|
|
62
|
+
// Prefer recent short discoveries within a bounded allowance; large batches
|
|
63
|
+
// still share the remaining space. Prefix stability is measured separately.
|
|
64
|
+
const recent = new Set();
|
|
65
|
+
let recentBytes = 0;
|
|
66
|
+
for (const slot of [...slots].reverse()) {
|
|
67
|
+
const size = bytes(slot[2]);
|
|
68
|
+
if (size <= 2048 && recentBytes + size <= Math.min(8192, budget / 4)) {
|
|
69
|
+
recent.add(slot); recentBytes += size;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const cap = limit => { for (const slot of slots) {
|
|
73
|
+
const [object, key, text] = slot;
|
|
74
|
+
object[key] = recent.has(slot) ? text : excerpt(text, limit);
|
|
75
|
+
} };
|
|
53
76
|
cap(0);
|
|
77
|
+
// Release older preferences first if the complete preferred set cannot fit.
|
|
78
|
+
// Even one useful recent result is better than uniformly shortening them all.
|
|
79
|
+
for (const slot of [...recent].reverse()) {
|
|
80
|
+
if (measure(original) <= budget) break;
|
|
81
|
+
recent.delete(slot); cap(0);
|
|
82
|
+
}
|
|
54
83
|
if (measure(original) > budget) throw new Error('Local Pi context budget cannot fit required instructions, tool pairs and truncation guidance; use smaller task/input or qualify the configured allocation. No fallback.');
|
|
55
84
|
let low = 0, high = Math.max(0, ...slots.map(([, , text]) => text.length));
|
|
56
85
|
while (low < high) {
|
|
@@ -1,19 +1,87 @@
|
|
|
1
1
|
// Explicitly mounted Factory adapter, never discovered from the host/repository.
|
|
2
2
|
import { readFileSync, writeSync } from 'node:fs';
|
|
3
3
|
import { openAICompletionsApi } from '@earendil-works/pi-ai';
|
|
4
|
-
import {
|
|
4
|
+
import { generateSummaryWithUsage } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { historyPressure, bytes, fitToolText, projectedTokens, restoreOutputBudget, validatePairs } from './pi-context-budget.mjs';
|
|
5
6
|
|
|
6
7
|
export default function factoryLocalContext(pi) {
|
|
7
8
|
const registry = JSON.parse(readFileSync(new URL('./models.json', import.meta.url), 'utf8'));
|
|
8
9
|
const configured = registry.providers['factory-local'].models[0];
|
|
9
10
|
const window = configured.contextWindow, output = configured.maxTokens;
|
|
10
|
-
let task;
|
|
11
|
+
let task, taskEntryId, envelope = 0, recoveries = 0, nextRecovery = 0;
|
|
11
12
|
// Pi catches extension exceptions and can continue. Budget violations must
|
|
12
13
|
// stop before transport, even during summarization, with a durable diagnostic.
|
|
13
14
|
const fatal = error => {
|
|
14
15
|
writeSync(2, `Factory local Pi context protection failed: ${error.message}\n`);
|
|
15
16
|
process.exit(1);
|
|
16
17
|
};
|
|
18
|
+
// Pi 0.87.1's public manual compact() aborts the active run. Use its
|
|
19
|
+
// supported completed-turn compaction draft instead: all tools have finished,
|
|
20
|
+
// and the next request rebuilds the canonical projection from this entry.
|
|
21
|
+
pi.on('turn_end', async (event, ctx) => {
|
|
22
|
+
if (event.outcome !== 'completed' || event.message.stopReason !== 'toolUse') return;
|
|
23
|
+
try {
|
|
24
|
+
const entries = event.context.contextEntries;
|
|
25
|
+
taskEntryId ??= entries.find(entry => entry.messages.some(m => m.role === 'user'))?.sourceEntry.id;
|
|
26
|
+
// Pi includes the system message in its boundary projection. The wire
|
|
27
|
+
// envelope already counts it and the restored task: count each once and
|
|
28
|
+
// use the same basis before/after, so removal cannot fake progress.
|
|
29
|
+
const history = list => list.filter(entry => entry.sourceEntry.id !== taskEntryId)
|
|
30
|
+
.flatMap(entry => entry.messages).filter(message => message.role !== 'system');
|
|
31
|
+
const budget = window - output;
|
|
32
|
+
const threshold = Math.max(Math.floor(budget * 0.65), nextRecovery);
|
|
33
|
+
const pressure = historyPressure(history(entries), envelope);
|
|
34
|
+
if (pressure < threshold) return;
|
|
35
|
+
const starts = entries.flatMap((entry, index) => entry.messages.some(m => m.role === 'assistant'
|
|
36
|
+
&& m.content?.some(p => p.type === 'toolCall')) ? [index] : []);
|
|
37
|
+
// The newest complete group is the retention floor, including immutable
|
|
38
|
+
// write arguments. Summarize the older prefix once; after seeing its real
|
|
39
|
+
// size we can retain up to four groups as additional recent evidence.
|
|
40
|
+
// Never split a group or summarize only part of a pending tool batch.
|
|
41
|
+
if (starts.length < 2) return;
|
|
42
|
+
const newest = starts.at(-1);
|
|
43
|
+
const floor = historyPressure(history(entries.slice(newest)), envelope);
|
|
44
|
+
const headroom = Math.min(8192, Math.floor(budget * 0.15));
|
|
45
|
+
// A large current call may leave no useful recovery yet. Let it reach the
|
|
46
|
+
// next ordinary turn under the unchanged wire guard, then it can enter
|
|
47
|
+
// the summarized prefix. Avoid paying for a predictably futile summary.
|
|
48
|
+
if (floor + 1024 > budget - headroom || pressure - floor < 4096) return;
|
|
49
|
+
if (++recoveries > 16) throw new Error('Local Pi proactive recovery limit reached; split the task. No fallback.');
|
|
50
|
+
if (ctx.signal?.aborted) throw new Error('Local Pi proactive recovery cancelled');
|
|
51
|
+
// Pi's summary serializer ignores system messages. Do not charge their
|
|
52
|
+
// bytes against the summary input projection; generation still keeps the
|
|
53
|
+
// exact system envelope. Keep the task and all summarized tool pairs.
|
|
54
|
+
const older = entries.slice(0, newest).flatMap(entry => entry.messages).filter(m => m.role !== 'system');
|
|
55
|
+
writeSync(1, `${JSON.stringify({ type: 'factory_context_recovery', phase: 'start', pressure, threshold, floor, envelope, groups: starts.length, recovery: recoveries })}\n`);
|
|
56
|
+
const input = fitToolText(older, window - output - 8192, bytes, true);
|
|
57
|
+
const result = await generateSummaryWithUsage(input, ctx.model, Math.min(16384, output),
|
|
58
|
+
'factory-local-keyless', undefined, ctx.signal,
|
|
59
|
+
'Preserve the task, verified edits, failed attempts, discoveries and remaining work. Do not claim completion without evidence.',
|
|
60
|
+
undefined, ctx.thinkingLevel, streamLocal, undefined, { enabled: true, maxRetries: 2, baseDelayMs: 250 });
|
|
61
|
+
if (ctx.signal?.aborted) throw new Error('Local Pi proactive recovery cancelled');
|
|
62
|
+
if (!result.text?.trim()) throw new Error('Local Pi proactive recovery returned an empty summary');
|
|
63
|
+
// Include framing for Pi's summary message. A fixed percentage cannot be
|
|
64
|
+
// required below the immutable envelope/newest-group floor. Bound both
|
|
65
|
+
// remaining headroom and actual progress instead, using the real summary.
|
|
66
|
+
const summaryBytes = bytes(result.text) + 1024;
|
|
67
|
+
const target = Math.min(budget - headroom, pressure - 4096,
|
|
68
|
+
Math.max(Math.floor(budget * 0.65 * 0.75), floor + summaryBytes));
|
|
69
|
+
let cut, after;
|
|
70
|
+
for (const candidate of starts.slice(-4)) {
|
|
71
|
+
const candidatePressure = historyPressure(history(entries.slice(candidate)), envelope + summaryBytes);
|
|
72
|
+
if (candidatePressure <= target) { cut = candidate; after = candidatePressure; break; }
|
|
73
|
+
}
|
|
74
|
+
if (cut === undefined) throw new Error('Local Pi proactive recovery did not free enough budget; split the task. No fallback.');
|
|
75
|
+
// Re-arm above the measured retained floor, so a productive large write
|
|
76
|
+
// does not cause an immediate repeat summary with nothing new to reclaim.
|
|
77
|
+
nextRecovery = Math.min(budget - Math.floor(headroom / 2), after + 4096);
|
|
78
|
+
writeSync(1, `${JSON.stringify({ type: 'factory_context_recovery', phase: 'complete', pressure: after, target,
|
|
79
|
+
summaryBytes, keptGroups: starts.filter(index => index >= cut).length, recovery: recoveries })}\n`);
|
|
80
|
+
return { entries: [{ type: 'compaction', summary: result.text,
|
|
81
|
+
firstKeptEntryId: entries[cut].sourceEntry.id, usage: result.usage,
|
|
82
|
+
details: { factoryLocalRecovery: 1 } }] };
|
|
83
|
+
} catch (error) { fatal(error); }
|
|
84
|
+
});
|
|
17
85
|
pi.on('session_before_compact', event => {
|
|
18
86
|
try {
|
|
19
87
|
// These are request-local copies. Upstream still selects cut points and
|
|
@@ -25,39 +93,39 @@ export default function factoryLocalContext(pi) {
|
|
|
25
93
|
preparation[key] = fitToolText(preparation[key], budget, bytes, true);
|
|
26
94
|
} catch (error) { fatal(error); }
|
|
27
95
|
});
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
streamSimple
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
payload
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
});
|
|
96
|
+
function streamLocal(model, context, options) {
|
|
97
|
+
if (model.id !== configured.id || model.provider !== 'factory-local') fatal(new Error('Selected local model changed'));
|
|
98
|
+
// Capture before streamSimple/buildBaseOptions clamps against unprojected
|
|
99
|
+
// history. Generation defaults to the binding; summaries supply their cap.
|
|
100
|
+
const requestedOutput = options?.maxTokens ?? output;
|
|
101
|
+
return openAICompletionsApi().streamSimple(model, context, {
|
|
102
|
+
...options,
|
|
103
|
+
onPayload: async (payload, requestModel) => {
|
|
104
|
+
try {
|
|
105
|
+
payload = await options?.onPayload?.(payload, requestModel) ?? payload;
|
|
106
|
+
payload = structuredClone(payload);
|
|
107
|
+
if (payload.model !== configured.id
|
|
108
|
+
|| !payload.messages?.some(m => m.role === 'system')) throw new Error('Unsupported local request structure or output budget');
|
|
109
|
+
restoreOutputBudget(payload, configured, requestedOutput, options?.reasoning);
|
|
110
|
+
if (payload.tools?.length) {
|
|
111
|
+
task ??= structuredClone(payload.messages.find(m => m.role === 'user'));
|
|
112
|
+
if (!task) throw new Error('Missing required task');
|
|
113
|
+
if (!payload.messages.some(m => m.role === 'user' && JSON.stringify(m) === JSON.stringify(task)))
|
|
114
|
+
payload.messages.splice(1, 0, structuredClone(task));
|
|
115
|
+
validatePairs(payload.messages);
|
|
116
|
+
envelope = bytes(payload.tools) + bytes(payload.messages.filter(m => m.role === 'system'))
|
|
117
|
+
+ bytes(task) + 4096;
|
|
118
|
+
}
|
|
119
|
+
if (!payload.messages.some(m => m.role === 'user')) throw new Error('Missing required user message');
|
|
120
|
+
// Restore BEFORE measuring so the final output field is counted.
|
|
121
|
+
// No request is sent unless projection fits with the full configured
|
|
122
|
+
// reserve, even when a summary requests a smaller bounded output.
|
|
123
|
+
return fitToolText(payload, window - output, projectedTokens);
|
|
124
|
+
} catch (error) { fatal(error); }
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
pi.registerProvider('factory-local', { api: 'openai-completions', streamSimple: streamLocal });
|
|
61
129
|
// Launcher requires this handshake; an extension load failure cannot bypass it.
|
|
62
130
|
writeSync(1, `${JSON.stringify({ type: 'factory_context_ready', version: 1 })}\n`);
|
|
63
131
|
}
|
|
@@ -24,6 +24,8 @@ export class PiCompletion {
|
|
|
24
24
|
settled = false;
|
|
25
25
|
lastAssistant = null;
|
|
26
26
|
contextReady = false;
|
|
27
|
+
proactiveRecovery = false;
|
|
28
|
+
proactiveCount = 0;
|
|
27
29
|
|
|
28
30
|
write(chunk) {
|
|
29
31
|
this.pending += chunk;
|
|
@@ -42,6 +44,17 @@ export class PiCompletion {
|
|
|
42
44
|
event(value) {
|
|
43
45
|
if (!value || typeof value.type !== 'string') { this.failed = true; return; }
|
|
44
46
|
if (value.type === 'factory_context_ready' && value.version === 1) this.contextReady = true;
|
|
47
|
+
if (value.type === 'factory_context_recovery') {
|
|
48
|
+
if (value.phase === 'start') {
|
|
49
|
+
if (this.proactiveRecovery || value.recovery !== this.proactiveCount + 1 || value.recovery > 16) this.failed = true;
|
|
50
|
+
this.proactiveCount = value.recovery;
|
|
51
|
+
this.proactiveRecovery = true;
|
|
52
|
+
this.settled = false;
|
|
53
|
+
} else if (value.phase === 'complete') {
|
|
54
|
+
if (!this.proactiveRecovery || value.recovery !== this.proactiveCount) this.failed = true;
|
|
55
|
+
this.proactiveRecovery = false;
|
|
56
|
+
} else this.failed = true;
|
|
57
|
+
}
|
|
45
58
|
if (value.type === 'agent_start' || value.type === 'message_start') {
|
|
46
59
|
this.completed = false; this.settled = false;
|
|
47
60
|
}
|
|
@@ -95,7 +108,7 @@ export class PiCompletion {
|
|
|
95
108
|
|
|
96
109
|
finish() {
|
|
97
110
|
return !this.pending && !this.failed && !this.error && !this.retry && !this.compacting && !this.recovery
|
|
98
|
-
&& this.completed && (!this.modern || this.settled);
|
|
111
|
+
&& !this.proactiveRecovery && this.completed && (!this.modern || this.settled);
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
|
|
@@ -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')); }
|