atris 3.71.1 → 3.71.3
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/atris/skills/campaigns/SKILL.md +10 -6
- package/atris.md +9 -0
- package/bin/atris.js +1 -1
- package/commands/campaign.js +219 -15
- package/commands/improve.js +3 -2
- package/commands/mission.js +33 -11
- package/commands/pulse.js +89 -5
- package/commands/worktree.js +24 -2
- package/lib/campaign-cadence.js +49 -13
- package/lib/campaign-core.js +72 -7
- package/lib/campaign-keep.js +91 -0
- package/lib/campaign-lanes.js +57 -0
- package/lib/campaign-loop.js +178 -33
- package/lib/campaign-protect.js +7 -1
- package/lib/campaign-rsi.js +680 -0
- package/lib/campaign-run-lock.js +9 -1
- package/lib/campaign-schema.js +3 -1
- package/lib/campaign-trace.js +42 -20
- package/lib/mission-code-provenance.js +118 -0
- package/lib/pulse.js +2 -1
- package/lib/rsi-record.js +88 -0
- package/lib/task-receipt.js +24 -6
- package/package.json +1 -1
package/lib/campaign-schema.js
CHANGED
|
@@ -32,7 +32,9 @@ const STATES = Object.freeze([
|
|
|
32
32
|
|
|
33
33
|
const STOP_REASONS = Object.freeze(['judge_passed', 'stopped', 'done_by_human', 'budget_spent']);
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// A fault is a job that never ran (engine missing, crash before the first
|
|
36
|
+
// step): the setup broke, not the work.
|
|
37
|
+
const MISSION_OUTCOMES = Object.freeze(['kept', 'reverted', 'failed', 'fault']);
|
|
36
38
|
|
|
37
39
|
// What a person or agent writes. Everything else is folded from events.
|
|
38
40
|
const CAMPAIGN_FIELDS = Object.freeze({
|
package/lib/campaign-trace.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
9
|
const crypto = require('crypto');
|
|
10
|
-
const { compareEvents, fold } = require('./campaign-core');
|
|
10
|
+
const { compareEvents, fold, foldWithPrefixes } = require('./campaign-core');
|
|
11
11
|
const { judgeReading } = require('./campaign-reading');
|
|
12
12
|
|
|
13
13
|
const SCHEMA = 'atris.campaign.trace';
|
|
@@ -18,7 +18,7 @@ const PRIVACY = {
|
|
|
18
18
|
full: 'Full: includes titles, objectives, verify commands, the judge command, judge summaries and output tails, and ask text. Export this only for your own data, or with the written consent of everyone in the campaign.',
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. Missions that ended between the same two judge runs form a batch
|
|
21
|
+
const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. An accepted judge reset between those runs makes the comparison unknown. Missions that ended between the same two judge runs form a batch; eligible missions split the change equally. Setup faults never ran, so they have no work reward and take no share of that change.';
|
|
22
22
|
|
|
23
23
|
function round(value, places = 6) {
|
|
24
24
|
const scale = 10 ** places;
|
|
@@ -84,14 +84,20 @@ function buildTrace(events, options = {}) {
|
|
|
84
84
|
|
|
85
85
|
const missions = new Map();
|
|
86
86
|
const judgeRuns = [];
|
|
87
|
+
const judgeChanges = [];
|
|
88
|
+
// Use the fold's authorization decision rather than a second owner rule.
|
|
89
|
+
const ignoredJudgeChanges = new Set(final.warnings
|
|
90
|
+
.filter(warning => warning.code === 'judge_set_not_owner')
|
|
91
|
+
.map(warning => warning.event_id));
|
|
87
92
|
const asks = new Map();
|
|
88
93
|
const info = (id) => {
|
|
89
|
-
if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
|
|
94
|
+
if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, executionEngines: [], kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
|
|
90
95
|
return missions.get(id);
|
|
91
96
|
};
|
|
92
97
|
|
|
93
98
|
sequence.forEach((event, pos) => {
|
|
94
99
|
const d = event.data;
|
|
100
|
+
if (event.type === 'judge_set' && !ignoredJudgeChanges.has(event.id)) judgeChanges.push(pos);
|
|
95
101
|
if (event.type === 'judge_ran') {
|
|
96
102
|
judgeRuns.push({ pos, at: event.at, passed: d.passed, reading: d.reading, summary: d.summary || null, outputTail: d.output_tail || null });
|
|
97
103
|
return;
|
|
@@ -117,8 +123,10 @@ function buildTrace(events, options = {}) {
|
|
|
117
123
|
if (typeof d.engine === 'string' && event.type === 'mission_started' && d.kind === 'plan') m.engine = d.engine;
|
|
118
124
|
if (typeof d.estimate_usd === 'number' && m.estimate === null) m.estimate = d.estimate_usd;
|
|
119
125
|
if (event.type === 'mission_started' && d.kind === 'plan') m.kind = 'plan';
|
|
126
|
+
if (event.type === 'mission_started' && !m.startedAt) m.startedAt = event.at;
|
|
120
127
|
if (event.type === 'step') {
|
|
121
128
|
m.ticks += 1;
|
|
129
|
+
if (d.kind !== 'plan') m.executionEngines.push(typeof d.engine === 'string' && d.engine ? d.engine : null);
|
|
122
130
|
if (!(typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0)) m.uncostedTicks += 1;
|
|
123
131
|
}
|
|
124
132
|
if (event.type === 'mission_ended' && m.endPos === null) {
|
|
@@ -129,12 +137,13 @@ function buildTrace(events, options = {}) {
|
|
|
129
137
|
});
|
|
130
138
|
for (const ask of asks.values()) if (ask.mission_id && missions.has(ask.mission_id)) missions.get(ask.mission_id).asks.push(ask);
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
const ended = [...missions.values()].filter((m) => m.endPos !== null).sort((a, b) => a.endPos - b.endPos);
|
|
141
|
+
// Reuse one event traversal and retain only the compact state needed for
|
|
142
|
+
// ended missions. Reading lookup below still uses the original position.
|
|
143
|
+
const prefixStates = ended.length
|
|
144
|
+
? foldWithPrefixes(sequence, ended.map((m) => Math.max(1, m.firstPos === null ? m.endPos : m.firstPos)),
|
|
145
|
+
(view) => compactState(view, null)).prefixes
|
|
146
|
+
: new Map();
|
|
138
147
|
const lastRunBefore = (pos) => {
|
|
139
148
|
let found = null;
|
|
140
149
|
for (const run of judgeRuns) if (run.pos < pos) found = run;
|
|
@@ -156,7 +165,7 @@ function buildTrace(events, options = {}) {
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
const finalMissions = new Map(final.missions.map((m) => [m.id, m]));
|
|
159
|
-
const
|
|
168
|
+
const outcomeOf = (m) => (finalMissions.get(m.id) || {}).outcome || m.endData.outcome;
|
|
160
169
|
|
|
161
170
|
// A batch: missions that ended between the same two judge runs.
|
|
162
171
|
const batches = new Map();
|
|
@@ -164,8 +173,10 @@ function buildTrace(events, options = {}) {
|
|
|
164
173
|
const before = lastRunBefore(m.endPos);
|
|
165
174
|
const after = firstRunAfter(m.endPos);
|
|
166
175
|
const key = `${before ? before.pos : -1}:${after ? after.pos : 'open'}`;
|
|
167
|
-
if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [] });
|
|
168
|
-
batches.get(key)
|
|
176
|
+
if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [], eligible: 0 });
|
|
177
|
+
const batch = batches.get(key);
|
|
178
|
+
batch.missions.push(m);
|
|
179
|
+
if (outcomeOf(m) !== 'fault') batch.eligible += 1;
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
const memberFor = (member) => (redact && typeof member === 'string' && member.startsWith('human:') ? 'human' : member);
|
|
@@ -178,23 +189,31 @@ function buildTrace(events, options = {}) {
|
|
|
178
189
|
const after = firstRunAfter(m.endPos);
|
|
179
190
|
const batch = batches.get(`${before ? before.pos : -1}:${after ? after.pos : 'open'}`);
|
|
180
191
|
const size = batch.missions.length;
|
|
192
|
+
const outcome = outcomeOf(m);
|
|
193
|
+
const judgeChanged = before && after && judgeChanges.some(pos => pos > before.pos && pos < after.pos);
|
|
181
194
|
const readBefore = readingOf(before);
|
|
182
|
-
|
|
195
|
+
// A replacement baseline is real campaign state, but cannot score earlier
|
|
196
|
+
// work. Null also prevents a replacement pass becoming fallback RSI proof.
|
|
197
|
+
const readAfter = readingOf(judgeChanged ? null : after);
|
|
183
198
|
const delta = readBefore.value !== null && readAfter.value !== null ? readAfter.value - readBefore.value : null;
|
|
184
199
|
const startPos = m.firstPos === null ? m.endPos : m.firstPos;
|
|
185
|
-
const view = viewBefore(startPos);
|
|
186
200
|
const folded = finalMissions.get(m.id) || {};
|
|
187
201
|
const uncosted = typeof m.endData.uncosted_runs === 'number' ? m.endData.uncosted_runs : m.uncostedTicks;
|
|
188
202
|
let creditNote = null;
|
|
189
|
-
if (
|
|
203
|
+
if (outcome === 'fault') creditNote = 'this mission never ran (setup fault), so it has no work reward and takes no share of the reading change';
|
|
204
|
+
else if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
|
|
190
205
|
else if (!before) creditNote = 'no judge run before this mission, so it has no reward';
|
|
206
|
+
else if (judgeChanged) creditNote = 'the judge changed between readings, so this mission has no comparable reward';
|
|
207
|
+
else if (batch.eligible < size) creditNote = `one of ${batch.eligible} eligible missions in this batch; setup faults are excluded and the reading change is split equally`;
|
|
191
208
|
else if (size > 1) creditNote = `one of ${size} missions that ended between the same two judge runs; the reading change is split equally`;
|
|
192
209
|
|
|
193
210
|
const action = {
|
|
194
211
|
mission_id: idFor(m.id),
|
|
195
212
|
kind: m.kind,
|
|
196
213
|
member: memberFor(folded.member || m.member),
|
|
197
|
-
engine: m.
|
|
214
|
+
engine: m.executionEngines.length
|
|
215
|
+
? new Set(m.executionEngines).size === 1 ? m.executionEngines[0] : null
|
|
216
|
+
: m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
|
|
198
217
|
estimate_usd: m.estimate,
|
|
199
218
|
};
|
|
200
219
|
if (!redact) {
|
|
@@ -207,9 +226,9 @@ function buildTrace(events, options = {}) {
|
|
|
207
226
|
step: i + 1,
|
|
208
227
|
at: m.endAt,
|
|
209
228
|
batch: { index: batch.index, size },
|
|
210
|
-
state_before:
|
|
229
|
+
state_before: { ...prefixStates.get(Math.max(1, startPos)), reading: readingOf(lastRunBefore(startPos)).value },
|
|
211
230
|
action,
|
|
212
|
-
outcome
|
|
231
|
+
outcome,
|
|
213
232
|
cost_usd: folded.cost_usd === undefined || folded.cost_usd === null ? m.endData.cost_usd : folded.cost_usd,
|
|
214
233
|
ticks: m.ticks,
|
|
215
234
|
uncosted_ticks: uncosted,
|
|
@@ -217,12 +236,15 @@ function buildTrace(events, options = {}) {
|
|
|
217
236
|
reading_before: readBefore.value,
|
|
218
237
|
reading_after: readAfter.value,
|
|
219
238
|
reading_source: readAfter.source,
|
|
220
|
-
reward: delta === null ? null : round(delta /
|
|
239
|
+
reward: outcome === 'fault' || delta === null ? null : round(delta / batch.eligible),
|
|
221
240
|
credit_note: creditNote,
|
|
222
241
|
done: finishPos !== null && finishPos <= (after ? after.pos : lastPos),
|
|
223
|
-
judge_passed: after ? after.passed : null,
|
|
242
|
+
judge_passed: after && !judgeChanged ? after.passed : null,
|
|
224
243
|
};
|
|
225
244
|
if (!redact) {
|
|
245
|
+
row.started_at = m.startedAt || null;
|
|
246
|
+
row.receipt_path = typeof m.endData.receipt_path === 'string' ? m.endData.receipt_path : null;
|
|
247
|
+
row.execution_engines = m.executionEngines;
|
|
226
248
|
row.judge_summary = after ? after.summary : null;
|
|
227
249
|
row.judge_output_tail = after ? after.outputTail : null;
|
|
228
250
|
row.asks = m.asks.map((ask) => ({ question: ask.question, answer: ask.answer }));
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { devNull } = require('os');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const BASELINE_SCHEMA = 'atris.mission_code_baseline.v1';
|
|
8
|
+
const PROVENANCE_SCHEMA = 'atris.mission_code_provenance.v1';
|
|
9
|
+
const BOOKKEEPING = ['.atris/', 'atris/runs/', 'atris/status/'];
|
|
10
|
+
|
|
11
|
+
function fail(reason) {
|
|
12
|
+
throw new Error(reason);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function git(root, args, reason = 'git_command_failed') {
|
|
16
|
+
// Disabling replacements alone still permits info/grafts and inherited graft
|
|
17
|
+
// files to rewrite ancestry. Evidence needs the actual commit relationships.
|
|
18
|
+
const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_GRAFT_FILE: devNull };
|
|
19
|
+
// Evidence belongs to this on-disk repository, even when the caller was
|
|
20
|
+
// launched by a Git hook with another index or repository in its environment.
|
|
21
|
+
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_SHALLOW_FILE']) delete env[key];
|
|
22
|
+
const result = spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], {
|
|
23
|
+
cwd: root, env, timeout: 15000, maxBuffer: Infinity,
|
|
24
|
+
});
|
|
25
|
+
if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) fail(reason);
|
|
26
|
+
const text = result.stdout.toString('utf8');
|
|
27
|
+
// A lossy filename decode cannot stand in for a complete path list.
|
|
28
|
+
if (!Buffer.from(text, 'utf8').equals(result.stdout)) fail('git_output_unreadable');
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function repositoryRoot(root) {
|
|
33
|
+
const physical = fs.realpathSync(root);
|
|
34
|
+
const top = git(physical, ['rev-parse', '--show-toplevel'], 'not_git_repository').replace(/\n$/, '');
|
|
35
|
+
if (fs.realpathSync(top) !== physical) fail('root_not_git_top_level');
|
|
36
|
+
return physical;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nulEntries(text) {
|
|
40
|
+
if (!text) return [];
|
|
41
|
+
if (!text.endsWith('\0')) fail('git_output_incomplete');
|
|
42
|
+
return text.slice(0, -1).split('\0');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function knownCommit(root, commit) {
|
|
46
|
+
if (typeof commit !== 'string' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) fail('commit_unavailable');
|
|
47
|
+
if (git(root, ['cat-file', '-t', commit], 'commit_unavailable').trim() !== 'commit') fail('commit_unavailable');
|
|
48
|
+
return commit;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cleanHead(root) {
|
|
52
|
+
const head = knownCommit(root, git(root, ['rev-parse', '--verify', 'HEAD'], 'head_unavailable').trim());
|
|
53
|
+
// Disabling rename detection exposes both paths, including a product file
|
|
54
|
+
// moved into a bookkeeping directory. Every untracked file is inspected.
|
|
55
|
+
const entries = nulEntries(git(root, [
|
|
56
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=all', '--no-renames', '--ignore-submodules=none',
|
|
57
|
+
], 'git_status_unavailable'));
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (entry.length < 4 || entry[2] !== ' ') fail('git_status_unreadable');
|
|
60
|
+
if (!BOOKKEEPING.some((prefix) => entry.slice(3).startsWith(prefix))) fail('product_dirty');
|
|
61
|
+
}
|
|
62
|
+
if (git(root, ['rev-parse', '--verify', 'HEAD'], 'head_unavailable').trim() !== head) fail('head_changed_during_capture');
|
|
63
|
+
return head;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function committedFiles(root, base, head) {
|
|
67
|
+
knownCommit(root, base);
|
|
68
|
+
knownCommit(root, head);
|
|
69
|
+
git(root, ['merge-base', '--is-ancestor', base, head], 'base_not_ancestor');
|
|
70
|
+
// No sample, path filter, or rename folding: deleted and renamed source
|
|
71
|
+
// paths matter as much as additions, including committed bookkeeping.
|
|
72
|
+
return nulEntries(git(root, [
|
|
73
|
+
'diff', '--name-only', '--no-renames', '--no-ext-diff', '--no-textconv', '-z', base, head, '--',
|
|
74
|
+
], 'git_diff_unavailable'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unavailable(schema, error) {
|
|
78
|
+
return { schema, status: 'unavailable', reason: error?.message || 'git_evidence_unavailable' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function captureCodeBaseline(root) {
|
|
82
|
+
try {
|
|
83
|
+
const repo_root = repositoryRoot(root);
|
|
84
|
+
return { schema: BASELINE_SCHEMA, status: 'complete', repo_root, base_commit: cleanHead(repo_root) };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return unavailable(BASELINE_SCHEMA, error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectCodeProvenance(root, baseline) {
|
|
91
|
+
try {
|
|
92
|
+
if (baseline?.schema !== BASELINE_SCHEMA || baseline.status !== 'complete') fail('baseline_unavailable');
|
|
93
|
+
const repo_root = repositoryRoot(root);
|
|
94
|
+
if (baseline.repo_root !== repo_root) fail('baseline_repository_mismatch');
|
|
95
|
+
const head_commit = cleanHead(repo_root);
|
|
96
|
+
const base_commit = baseline.base_commit;
|
|
97
|
+
const files = committedFiles(repo_root, base_commit, head_commit);
|
|
98
|
+
return { schema: PROVENANCE_SCHEMA, status: 'complete', repo_root, base_commit, head_commit, files };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return unavailable(PROVENANCE_SCHEMA, error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function verifyCodeProvenance(root, provenance) {
|
|
105
|
+
try {
|
|
106
|
+
if (provenance?.schema !== PROVENANCE_SCHEMA || provenance.status !== 'complete' || !Array.isArray(provenance.files)) fail('provenance_unavailable');
|
|
107
|
+
const repo_root = repositoryRoot(root);
|
|
108
|
+
if (provenance.repo_root !== repo_root) fail('provenance_repository_mismatch');
|
|
109
|
+
// Historical evidence stays valid when the current checkout moves on.
|
|
110
|
+
const actual = committedFiles(repo_root, provenance.base_commit, provenance.head_commit);
|
|
111
|
+
if (actual.length !== provenance.files.length || actual.some((file, index) => file !== provenance.files[index])) fail('provenance_files_mismatch');
|
|
112
|
+
return { ok: true };
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { ok: false, reason: error?.message || 'git_evidence_unavailable' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { captureCodeBaseline, collectCodeProvenance, verifyCodeProvenance };
|
package/lib/pulse.js
CHANGED
|
@@ -603,6 +603,7 @@ function buildTickScript(opts = {}) {
|
|
|
603
603
|
pathDirs = [],
|
|
604
604
|
launchLabel = '',
|
|
605
605
|
launchPlistPath = '',
|
|
606
|
+
tickFlags = [],
|
|
606
607
|
} = opts;
|
|
607
608
|
if (!root) throw new Error('buildTickScript: root is required');
|
|
608
609
|
if (!stateHome) throw new Error('buildTickScript: stateHome is required');
|
|
@@ -690,7 +691,7 @@ ${runnerBinExport}
|
|
|
690
691
|
${runnerCommandTemplateExport}
|
|
691
692
|
export ATRIS_SKIP_UPDATE_CHECK=1
|
|
692
693
|
|
|
693
|
-
"$ATRIS" pulse tick --json${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
|
|
694
|
+
"$ATRIS" pulse tick --json${tickFlags.filter((f) => /^--[a-z-]+$/.test(f)).map((f) => ` ${f}`).join('')}${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
|
|
694
695
|
tick_status=$?
|
|
695
696
|
|
|
696
697
|
# runDaily no-ops via last_run_date after the first daily run.
|
package/lib/rsi-record.js
CHANGED
|
@@ -320,6 +320,93 @@ function finishAttempt(root, attempt, outcome = {}, { log = () => {} } = {}) {
|
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
// Observed history did not run under today's policy. Use the schema directly:
|
|
324
|
+
// open/finish would invent current action and completion times for old work.
|
|
325
|
+
const OBSERVED_APPEND_PY = `import json, sys
|
|
326
|
+
from dataclasses import asdict
|
|
327
|
+
from datetime import datetime
|
|
328
|
+
sys.path.insert(0, sys.argv[1])
|
|
329
|
+
from backend.scripts.rsi.schema import Action, Context, Node, Outcome, append_node, tree_split
|
|
330
|
+
from backend.scripts.rsi.record import get_node
|
|
331
|
+
data = json.loads(sys.argv[2])
|
|
332
|
+
def instant(value): return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
333
|
+
if data.get('nodeId'):
|
|
334
|
+
node = get_node(data['nodeId'])
|
|
335
|
+
if (node is None or node.lane != data['lane'] or
|
|
336
|
+
node.context.ground != data['ground'] or node.action.ground != data['ground'] or
|
|
337
|
+
node.action.engine != data['engine'] or node.action.policy_id != 'p-0000' or
|
|
338
|
+
node.context.base_commit != data['baseCommit'] or node.outcome.status == 'running' or
|
|
339
|
+
instant(node.opened_at) != instant(data['openedAt']) or
|
|
340
|
+
instant(node.created_at) != instant(data['finishedAt'])):
|
|
341
|
+
raise ValueError('observed attempt identity, policy, or provenance does not match')
|
|
342
|
+
outcome = asdict(node.outcome)
|
|
343
|
+
outcome.update(data['outcome'])
|
|
344
|
+
node.outcome = Outcome(**outcome)
|
|
345
|
+
node.recorded_at = None
|
|
346
|
+
else:
|
|
347
|
+
node = Node(
|
|
348
|
+
tree_id=data['tree'], lane=data['lane'],
|
|
349
|
+
context=Context(base_commit=data['baseCommit'], ground=data['ground']),
|
|
350
|
+
action=Action(policy_id='p-0000', kind='open', ground=data['ground'], engine=data['engine'], model=None, cap_s=0),
|
|
351
|
+
outcome=Outcome(**data['outcome']), split=tree_split(data['tree']),
|
|
352
|
+
created_at=data['finishedAt'], opened_at=data['openedAt'])
|
|
353
|
+
append_node(node)
|
|
354
|
+
print(node.id)
|
|
355
|
+
`;
|
|
356
|
+
|
|
357
|
+
function observedTime(value) {
|
|
358
|
+
if (typeof value !== 'string') return NaN;
|
|
359
|
+
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/);
|
|
360
|
+
if (!match) return NaN;
|
|
361
|
+
const year = Number(match[1]);
|
|
362
|
+
const month = Number(match[2]);
|
|
363
|
+
const day = Number(match[3]);
|
|
364
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
365
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
366
|
+
if (month < 1 || month > 12 || day < 1 || day > days[month - 1]) return NaN;
|
|
367
|
+
return Date.parse(value);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Append measured completed work, preserving its historical provenance.
|
|
371
|
+
* Missing metadata or an unavailable backend schema defers recording. This
|
|
372
|
+
* never chooses a policy and never changes the active-attempt environment.
|
|
373
|
+
*/
|
|
374
|
+
function recordObservedAttempt(root, options = {}, { log = () => {} } = {}) {
|
|
375
|
+
const report = (message) => { try { log(message); } catch {} };
|
|
376
|
+
try {
|
|
377
|
+
const { lane = 'campaign', ground, engine, baseCommit, openedAt, finishedAt, outcome, nodeId } = options;
|
|
378
|
+
const engines = new Set(['codex', 'devin', 'cursor', 'agy', 'claude']);
|
|
379
|
+
const opened = observedTime(openedAt);
|
|
380
|
+
const finished = observedTime(finishedAt);
|
|
381
|
+
if (!root || !recorderPath(root) || typeof lane !== 'string' || !lane.trim() ||
|
|
382
|
+
typeof ground !== 'string' || !ground.trim() || !engines.has(engine) ||
|
|
383
|
+
typeof baseCommit !== 'string' || !/^[a-fA-F0-9]{7,64}$/.test(baseCommit) ||
|
|
384
|
+
!Number.isFinite(opened) || !Number.isFinite(finished) || opened > finished || finished > Date.now() ||
|
|
385
|
+
!outcome || typeof outcome !== 'object' || Array.isArray(outcome) ||
|
|
386
|
+
!['shipped', 'failed', 'nothing', 'waiting'].includes(outcome.status) ||
|
|
387
|
+
!['pass', 'fail', 'skipped'].includes(outcome.verify) ||
|
|
388
|
+
!Array.isArray(outcome.files) || !outcome.files.length ||
|
|
389
|
+
!Array.from(outcome.files).every((file) => typeof file === 'string' && file.trim()) ||
|
|
390
|
+
(nodeId !== undefined && (typeof nodeId !== 'string' || !nodeId.trim()))) return null;
|
|
391
|
+
const tree = nodeId ? null : startTree(root, lane);
|
|
392
|
+
if (!nodeId && !tree) {
|
|
393
|
+
report('rsi: observed attempt skipped (could not select a tree)');
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const result = spawnSync(pythonBin(root), ['-c', OBSERVED_APPEND_PY, root,
|
|
397
|
+
JSON.stringify({ lane, ground, engine, baseCommit, openedAt, finishedAt, outcome, nodeId, tree })], {
|
|
398
|
+
cwd: root, encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, env: process.env,
|
|
399
|
+
});
|
|
400
|
+
const id = result.status === 0 ? String(result.stdout || '').trim().split('\n').pop() : '';
|
|
401
|
+
if (id) return id;
|
|
402
|
+
report(`rsi: observed attempt skipped${result.stderr ? ` (${String(result.stderr).trim().split('\n').pop()})` : ''}`);
|
|
403
|
+
return null;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
report(`rsi: observed attempt skipped (${error && error.message ? error.message : error})`);
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
323
410
|
module.exports = {
|
|
324
411
|
IMPROVE_LANE,
|
|
325
412
|
GUARD_ENV,
|
|
@@ -332,4 +419,5 @@ module.exports = {
|
|
|
332
419
|
engineFromModel,
|
|
333
420
|
beginAttempt,
|
|
334
421
|
finishAttempt,
|
|
422
|
+
recordObservedAttempt,
|
|
335
423
|
};
|
package/lib/task-receipt.js
CHANGED
|
@@ -43,6 +43,14 @@ function slugifyTaskId(taskId) {
|
|
|
43
43
|
.slice(0, 40) || 'task';
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function verifyTimeoutMs() {
|
|
47
|
+
// Match the audit and certification budget, including its one-hour ceiling.
|
|
48
|
+
const raw = Number(process.env.ATRIS_VERIFY_TIMEOUT_MS);
|
|
49
|
+
return Number.isFinite(raw) && raw > 0
|
|
50
|
+
? Math.min(Math.ceil(raw), 60 * 60 * 1000)
|
|
51
|
+
: 10 * 60 * 1000;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
// Runs `command` for `taskId`, writes atris/runs/<date>-task-<id>-<time>.json,
|
|
47
55
|
// and returns { ok, passed, receiptPath, exit, signal, output }. `ok` mirrors
|
|
48
56
|
// `passed`, kept as a separate field so callers reading either name work.
|
|
@@ -56,16 +64,24 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
56
64
|
// support. Put the node running atris first, after the profile has loaded.
|
|
57
65
|
const nodeDir = path.dirname(process.execPath).replace(/'/g, `'\\''`);
|
|
58
66
|
const script = `export PATH='${nodeDir}':"$PATH"\n${cmd}`;
|
|
67
|
+
const timeoutMs = verifyTimeoutMs();
|
|
59
68
|
const result = run('bash', ['-lc', script], {
|
|
60
69
|
cwd: root,
|
|
61
70
|
encoding: 'utf8',
|
|
62
|
-
timeout:
|
|
71
|
+
timeout: timeoutMs,
|
|
63
72
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
const failure = result.error ? {
|
|
74
|
+
error: result.error.message,
|
|
75
|
+
error_code: result.error.code || null,
|
|
76
|
+
timeout_ms: timeoutMs,
|
|
77
|
+
} : {};
|
|
78
|
+
const diagnostic = result.error
|
|
79
|
+
? (result.error.code === 'ETIMEDOUT'
|
|
80
|
+
? `\nverification timed out after ${timeoutMs / 1000} seconds.`
|
|
81
|
+
: '\nverification did not complete.')
|
|
82
|
+
: '';
|
|
83
|
+
const output = tailText(`${result.stdout || ''}${result.stderr || ''}${diagnostic}`);
|
|
84
|
+
const passed = !result.error && result.status === 0;
|
|
69
85
|
const dir = runsDir(root);
|
|
70
86
|
fs.mkdirSync(dir, { recursive: true });
|
|
71
87
|
const now = new Date();
|
|
@@ -84,6 +100,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
84
100
|
exit: result.status,
|
|
85
101
|
signal: result.signal || null,
|
|
86
102
|
output,
|
|
103
|
+
...failure,
|
|
87
104
|
},
|
|
88
105
|
};
|
|
89
106
|
fs.writeFileSync(receiptFile, JSON.stringify(receipt, null, 2) + '\n', 'utf8');
|
|
@@ -96,6 +113,7 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
|
|
|
96
113
|
signal: result.signal || null,
|
|
97
114
|
output,
|
|
98
115
|
cmd,
|
|
116
|
+
...(result.error ? { reason: 'verifier_spawn_failed', ...failure } : {}),
|
|
99
117
|
};
|
|
100
118
|
}
|
|
101
119
|
|