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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Protected lanes inside a campaign. A mission that touches billing, security,
|
|
4
|
+
// deploys, or customers pauses before its first tick (missionProtectedLaneHold).
|
|
5
|
+
// Inside a campaign that pause becomes an ask; a person answering yes approves
|
|
6
|
+
// that one lane for that one campaign. The approval lives in the campaign's own
|
|
7
|
+
// event log, where only a person's answer closes an ask.
|
|
8
|
+
|
|
9
|
+
const { isHumanActor } = require('./campaign-schema');
|
|
10
|
+
|
|
11
|
+
const LANE_ASK_KIND = 'protected_lane';
|
|
12
|
+
const YES = /^\s*(y|yes|ok|okay|approve|approved|allow|go|sure)\b/i;
|
|
13
|
+
const PAUSE_LINE = /paused: protected-lane-([a-z]+)/i;
|
|
14
|
+
|
|
15
|
+
// The lane a paused `atris mission run` reported, or null.
|
|
16
|
+
function pausedLane(output) {
|
|
17
|
+
const match = String(output || '').match(PAUSE_LINE);
|
|
18
|
+
return match ? match[1].toLowerCase() : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Lanes a person approved, from a campaign's events.
|
|
22
|
+
function approvedLanes(events) {
|
|
23
|
+
const asked = new Map();
|
|
24
|
+
const lanes = new Set();
|
|
25
|
+
for (const event of events || []) {
|
|
26
|
+
const data = event && event.data ? event.data : {};
|
|
27
|
+
if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) asked.set(data.ask_id, data.lane);
|
|
28
|
+
if (event.type === 'ask_answered' && asked.has(data.ask_id) && isHumanActor(event.actor) && YES.test(String(data.answer || ''))) {
|
|
29
|
+
lanes.add(asked.get(data.ask_id));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return lanes;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Lanes with an open ask in this campaign.
|
|
36
|
+
function openLaneAsks(events) {
|
|
37
|
+
const open = new Map();
|
|
38
|
+
for (const event of events || []) {
|
|
39
|
+
const data = event && event.data ? event.data : {};
|
|
40
|
+
if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) open.set(data.ask_id, data.lane);
|
|
41
|
+
if (event.type === 'ask_answered') open.delete(data.ask_id);
|
|
42
|
+
}
|
|
43
|
+
return new Set(open.values());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// True when this campaign already has an open ask for the lane, so the loop asks once.
|
|
47
|
+
function laneAskOpen(events, lane) {
|
|
48
|
+
return openLaneAsks(events).has(lane);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function laneQuestion(lane, member, objective) {
|
|
52
|
+
const what = String(objective || '').replace(/\s+/g, ' ').trim();
|
|
53
|
+
const short = what.length > 140 ? `${what.slice(0, 139)}…` : what;
|
|
54
|
+
return `${member}'s mission touches ${lane}: "${short}". Missions that touch ${lane} wait for you. Let this campaign work on ${lane}? Answer yes to allow it.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion, openLaneAsks };
|
package/lib/campaign-loop.js
CHANGED
|
@@ -10,13 +10,22 @@ const crypto = require('crypto');
|
|
|
10
10
|
const { spawn } = require('child_process');
|
|
11
11
|
const { decide } = require('./campaign-core');
|
|
12
12
|
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
13
|
-
const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
|
|
13
|
+
const { defaultReserve, missionCost, reconcileCampaign } = require('./campaign-missions');
|
|
14
14
|
const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
|
|
15
|
+
const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion, openLaneAsks } = require('./campaign-lanes');
|
|
15
16
|
|
|
16
17
|
const TERMINAL = new Set(['stopped', 'complete']);
|
|
17
18
|
|
|
18
19
|
const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
19
20
|
const PROPOSE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
|
+
// A campaign only the outside world can move checks its judge again after 10
|
|
22
|
+
// minutes, doubling while the reading stays the same, up to an hour.
|
|
23
|
+
const WAIT_FIRST_MS = 10 * 60 * 1000;
|
|
24
|
+
const WAIT_MAX_MS = 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
// Two readings are the same when they pass or fail alike and say the same thing.
|
|
27
|
+
const readingKey = (passed, reading, summary) => JSON.stringify([Boolean(passed), typeof reading === 'number' ? reading : null, summary || null]);
|
|
28
|
+
const clockTime = (ms) => new Date(ms).toISOString().slice(11, 16) + ' UTC';
|
|
20
29
|
|
|
21
30
|
function claimKey(objective) {
|
|
22
31
|
return String(objective).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
|
|
@@ -27,12 +36,56 @@ function proposalId(objective) {
|
|
|
27
36
|
return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
function rankCampaignMembers(view, rsiSummary = null) {
|
|
40
|
+
const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
|
|
41
|
+
const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
|
|
42
|
+
const rsiMap = new Map();
|
|
43
|
+
if (rsiSummary && Array.isArray(rsiSummary.members)) {
|
|
44
|
+
for (const row of rsiSummary.members) {
|
|
45
|
+
if (row && row.member) rsiMap.set(row.member, row);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const missions = Array.isArray(view && view.missions) ? view.missions : [];
|
|
49
|
+
const raw = candidates.map((member) => {
|
|
50
|
+
const info = rsiMap.get(member) || null;
|
|
51
|
+
const overall = info && typeof info.overall === 'number' ? info.overall : 75;
|
|
52
|
+
const overallLabel = (info && (info.overallLabel || info.note)) || 'unproven';
|
|
53
|
+
const repeatFailures = info && typeof info.repeatFailures === 'number'
|
|
54
|
+
? info.repeatFailures
|
|
55
|
+
: (info && typeof info.repeat_failures === 'number' ? info.repeat_failures : 0);
|
|
56
|
+
const mine = missions.filter((m) => m && m.member === member && m.status === 'ended');
|
|
57
|
+
let streak = 0;
|
|
58
|
+
for (let i = mine.length - 1; i >= 0; i -= 1) {
|
|
59
|
+
const outcome = String(mine[i].outcome || '');
|
|
60
|
+
if (outcome && outcome !== 'kept' && !mine[i].credited) streak += 1;
|
|
61
|
+
else break;
|
|
62
|
+
}
|
|
63
|
+
const cold = streak >= 2 || (overall < 60 && repeatFailures >= 3);
|
|
64
|
+
return { member, overall, overallLabel, consecutiveFailures: streak, cold, benched: false };
|
|
65
|
+
});
|
|
66
|
+
const hasWarmAlternative = raw.some((r) => !r.cold);
|
|
67
|
+
for (const row of raw) {
|
|
68
|
+
if (row.cold && hasWarmAlternative && raw.length > 1) row.benched = true;
|
|
69
|
+
}
|
|
70
|
+
return raw.sort((a, b) => {
|
|
71
|
+
if (a.benched !== b.benched) return a.benched ? 1 : -1;
|
|
72
|
+
if (b.overall !== a.overall) return b.overall - a.overall;
|
|
73
|
+
return a.consecutiveFailures - b.consecutiveFailures;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
// What the owner's engine reads before proposing: the goal, how it is judged,
|
|
31
78
|
// what has been tried, and how it went.
|
|
32
|
-
function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
|
|
33
|
-
const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none yet';
|
|
34
|
-
const
|
|
35
|
-
const
|
|
79
|
+
function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null) {
|
|
80
|
+
const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}${typeof m.reward === 'number' ? `, reward ${m.reward >= 0 ? '+' : ''}${m.reward}` : ''}`).join('\n') || '- none yet';
|
|
81
|
+
const ranked = rankCampaignMembers(view, rsiSummary);
|
|
82
|
+
const memberLine = ranked.map((r) => {
|
|
83
|
+
if (r.benched) return `${r.member} (${r.overall} ovr, benched after ${r.consecutiveFailures || 'repeat'} misses)`;
|
|
84
|
+
return rsiSummary ? `${r.member} (${r.overall} ovr, ${r.overallLabel})` : r.member;
|
|
85
|
+
}).join(', ');
|
|
86
|
+
const watchouts = ranked
|
|
87
|
+
.filter((r) => r.benched || r.consecutiveFailures > 0 || /repeat miss/i.test(r.overallLabel || ''))
|
|
88
|
+
.map((r) => `${r.member}: ${r.overallLabel}${r.benched ? ' (benched)' : ''}`);
|
|
36
89
|
return [
|
|
37
90
|
`You plan the next missions for an Atris campaign. Answer with JSON only.`,
|
|
38
91
|
``,
|
|
@@ -42,7 +95,8 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
|
|
|
42
95
|
judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
|
|
43
96
|
view.bet ? `Current bet: ${view.bet.text}${view.bet.wrong_if ? ` (wrong if ${view.bet.wrong_if})` : ''}` : '',
|
|
44
97
|
`Money left: $${view.budget.available.toFixed(2)} of $${view.budget.total.toFixed(2)}`,
|
|
45
|
-
`Members who can take a mission: ${
|
|
98
|
+
`Members who can take a mission: ${memberLine}`,
|
|
99
|
+
watchouts.length ? `Seat watchouts: ${watchouts.join('; ')}` : '',
|
|
46
100
|
`Missions so far:`,
|
|
47
101
|
tried,
|
|
48
102
|
lessons ? `Lessons from earlier rounds (follow them):\n${lessons}` : '',
|
|
@@ -54,7 +108,7 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
|
|
|
54
108
|
].filter((line) => line !== '').join('\n');
|
|
55
109
|
}
|
|
56
110
|
|
|
57
|
-
function parseProposals(text, view, count) {
|
|
111
|
+
function parseProposals(text, view, count, rsiSummary = null) {
|
|
58
112
|
const match = String(text || '').match(/\[[\s\S]*\]/);
|
|
59
113
|
if (!match) return [];
|
|
60
114
|
let rows;
|
|
@@ -64,12 +118,14 @@ function parseProposals(text, view, count) {
|
|
|
64
118
|
return [];
|
|
65
119
|
}
|
|
66
120
|
if (!Array.isArray(rows)) return [];
|
|
67
|
-
const
|
|
121
|
+
const ranked = rankCampaignMembers(view, rsiSummary);
|
|
122
|
+
const activeMembers = new Set(ranked.filter((r) => !r.benched).map((r) => r.member));
|
|
123
|
+
const fallbackMember = (ranked.find((r) => !r.benched) || ranked[0] || { member: view.owner }).member;
|
|
68
124
|
return rows.slice(0, count)
|
|
69
125
|
.filter((row) => row && typeof row.objective === 'string' && row.objective.trim())
|
|
70
126
|
.map((row) => ({
|
|
71
127
|
objective: row.objective.trim().slice(0, 300),
|
|
72
|
-
member:
|
|
128
|
+
member: activeMembers.has(row.member) ? row.member : fallbackMember,
|
|
73
129
|
verify: typeof row.verify === 'string' && row.verify.trim() ? row.verify.trim() : view.judge,
|
|
74
130
|
estimate_usd: typeof row.estimate_usd === 'number' && row.estimate_usd > 0 ? Math.min(row.estimate_usd, view.budget.available) : null,
|
|
75
131
|
}));
|
|
@@ -86,17 +142,37 @@ async function askOwner(root, view, prompt) {
|
|
|
86
142
|
}
|
|
87
143
|
|
|
88
144
|
// Run one atris command and resolve with its exit code and output.
|
|
145
|
+
// A live step an engine prints while a mission runs: "<member> <hh:mm> <what it is doing>".
|
|
146
|
+
const ACTIVITY_LINE = /^\s*[a-z0-9][a-z0-9-]*\s+\d{1,2}:\d{2}\s{2,}\S/i;
|
|
147
|
+
|
|
89
148
|
function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
|
|
90
149
|
return new Promise((resolve) => {
|
|
91
150
|
const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
92
151
|
let out = '';
|
|
93
152
|
let err = '';
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
153
|
+
// Whole lines only: a chunk can end mid-line. Live engine steps arrive on
|
|
154
|
+
// stderr (member-progress reportProgress), so both streams are read.
|
|
155
|
+
const reader = () => {
|
|
156
|
+
let partial = '';
|
|
157
|
+
return {
|
|
158
|
+
push(chunk) {
|
|
159
|
+
if (!onLine) return;
|
|
160
|
+
const lines = (partial + String(chunk)).split('\n');
|
|
161
|
+
partial = lines.pop();
|
|
162
|
+
lines.filter(Boolean).forEach(onLine);
|
|
163
|
+
},
|
|
164
|
+
end() { if (onLine && partial) onLine(partial); },
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
const stdout = reader();
|
|
168
|
+
const stderr = reader();
|
|
169
|
+
child.stdout.on('data', (chunk) => { out += chunk; stdout.push(chunk); });
|
|
170
|
+
child.stderr.on('data', (chunk) => { err += chunk; stderr.push(chunk); });
|
|
171
|
+
child.on('close', (code) => {
|
|
172
|
+
stdout.end();
|
|
173
|
+
stderr.end();
|
|
174
|
+
resolve({ code, out, err });
|
|
97
175
|
});
|
|
98
|
-
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
99
|
-
child.on('close', (code) => resolve({ code, out, err }));
|
|
100
176
|
});
|
|
101
177
|
}
|
|
102
178
|
|
|
@@ -111,20 +187,34 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
111
187
|
const runner = options.runner || 'auto';
|
|
112
188
|
const actor = options.actor || 'mission-lead';
|
|
113
189
|
const ask = deps.ask || askOwner;
|
|
114
|
-
const cli = deps.cli || ((args) => runCli(args, { cwd: root }));
|
|
190
|
+
const cli = deps.cli || ((args, extra = {}) => runCli(args, { cwd: root, ...extra }));
|
|
115
191
|
const judge = deps.judge;
|
|
192
|
+
const now = deps.now || Date.now;
|
|
116
193
|
|
|
117
194
|
const view = (id) => getCampaign(store, id);
|
|
118
195
|
const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
|
|
119
196
|
|
|
120
197
|
async function propose(id, count = parallel) {
|
|
121
198
|
const current = view(id);
|
|
122
|
-
const
|
|
199
|
+
const events = store.read(id);
|
|
200
|
+
let rsiSummary = null;
|
|
201
|
+
try {
|
|
202
|
+
const { missionRewardMap, summarizeStoreRsi } = require('./campaign-rsi');
|
|
203
|
+
const rewards = missionRewardMap(events);
|
|
204
|
+
current.missions = current.missions.map((m) => {
|
|
205
|
+
const raw = rewards && typeof rewards.get === 'function' ? rewards.get(m.id) : (rewards && rewards[m.id]);
|
|
206
|
+
const reward = typeof raw === 'number' ? raw : (raw && typeof raw.reward === 'number' ? raw.reward : undefined);
|
|
207
|
+
return typeof reward === 'number' ? { ...m, reward } : m;
|
|
208
|
+
});
|
|
209
|
+
rsiSummary = summarizeStoreRsi(store);
|
|
210
|
+
} catch {}
|
|
211
|
+
const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary);
|
|
123
212
|
const answer = await ask(root, current, prompt);
|
|
124
|
-
const proposals = parseProposals(answer.text, current, count);
|
|
213
|
+
const proposals = parseProposals(answer.text, current, count, rsiSummary);
|
|
125
214
|
// Planning costs money too. It rides on the first mission it produced as a
|
|
126
215
|
// step, so it is counted when that mission ends without making the judge
|
|
127
|
-
// due. A plan that produced nothing
|
|
216
|
+
// due. A plan that produced nothing is not failed work: the step saves a
|
|
217
|
+
// wait instead (the owner's engine reports no dollar cost to carry).
|
|
128
218
|
const plan = { kind: 'plan', engine: answer.engine };
|
|
129
219
|
if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
|
|
130
220
|
proposals.forEach((proposal, index) => {
|
|
@@ -133,11 +223,6 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
133
223
|
append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
|
|
134
224
|
if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
|
|
135
225
|
});
|
|
136
|
-
if (!proposals.length) {
|
|
137
|
-
const planId = `plan-${crypto.randomBytes(3).toString('hex')}`;
|
|
138
|
-
append(id, 'mission_started', { mission_id: planId, member: current.owner, kind: 'plan' }, current.owner);
|
|
139
|
-
append(id, 'mission_ended', { mission_id: planId, outcome: 'failed', cost_usd: plan.cost_usd || 0, uncosted_runs: plan.cost_usd === undefined ? 1 : 0, engine: answer.engine }, current.owner);
|
|
140
|
-
}
|
|
141
226
|
return proposals;
|
|
142
227
|
}
|
|
143
228
|
|
|
@@ -168,8 +253,30 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
168
253
|
return { mission_id: work.mission_id, started: false };
|
|
169
254
|
}
|
|
170
255
|
log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
|
|
171
|
-
|
|
256
|
+
// The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
|
|
257
|
+
// the campaign's own output, so a screen watching the run can show the work.
|
|
258
|
+
const stepsBefore = (view(id).missions.find((m) => m.id === work.mission_id) || {}).steps || 0;
|
|
259
|
+
const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass'], { onLine: (line) => { if (ACTIVITY_LINE.test(line)) log(` ${line.trim()}`); } });
|
|
172
260
|
const after = view(id).missions.find((m) => m.id === work.mission_id);
|
|
261
|
+
// A mission in a protected lane (billing, security, deploy, customer) pauses
|
|
262
|
+
// before its first tick. Ask a person once per lane instead of failing quietly.
|
|
263
|
+
const lane = after && after.status !== 'ended' ? pausedLane(`${ran.out || ''}\n${ran.err || ''}`) : null;
|
|
264
|
+
if (lane) {
|
|
265
|
+
if (!laneAskOpen(store.read(id), lane)) {
|
|
266
|
+
const askId = `a-${crypto.randomBytes(3).toString('hex')}`;
|
|
267
|
+
append(id, 'ask_opened', { ask_id: askId, question: laneQuestion(lane, work.member, work.objective), mission_id: work.mission_id, kind: LANE_ASK_KIND, lane }, current.owner);
|
|
268
|
+
}
|
|
269
|
+
await cli(['mission', 'stop', work.mission_id, '--reason', `campaign loop: waiting for a person to allow the ${lane} lane`]);
|
|
270
|
+
log(` ${work.mission_id} needs you: it touches ${lane}`);
|
|
271
|
+
return { mission_id: work.mission_id, started: true, outcome: null, needs: lane };
|
|
272
|
+
}
|
|
273
|
+
if (after && after.status !== 'ended' && after.steps === stepsBefore) {
|
|
274
|
+
// The run left no step behind: the job never ran. Score it as a fault
|
|
275
|
+
// with the run's own words, not as failed work by the member.
|
|
276
|
+
const why = `${ran.err || ''}\n${ran.out || ''}`.trim().split('\n').filter(Boolean).pop() || `exit ${ran.code}`;
|
|
277
|
+
append(id, 'mission_ended', { mission_id: work.mission_id, outcome: 'fault', reason: why.slice(0, 200), cost_usd: missionCost(root, work.mission_id, { store }).cost_usd }, work.member);
|
|
278
|
+
log(` ${work.mission_id} never ran: ${why}`);
|
|
279
|
+
}
|
|
173
280
|
if (after && after.status !== 'ended') {
|
|
174
281
|
// The run stopped without finishing (tick cap, error): close it so its
|
|
175
282
|
// reserve comes back and its cost is counted.
|
|
@@ -186,6 +293,21 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
186
293
|
// how many events it pulled.
|
|
187
294
|
const sync = deps.sync || (async () => 0);
|
|
188
295
|
|
|
296
|
+
// Run the judge and record its reading. waitFor(data) returns how long to
|
|
297
|
+
// wait before the next check (0 for no wait); a passing reading never waits.
|
|
298
|
+
async function readJudge(id, current, waitFor = () => 0) {
|
|
299
|
+
const reading = await judge(current.judge);
|
|
300
|
+
const data = judgeRanData(reading);
|
|
301
|
+
const waitMs = data.passed ? 0 : waitFor(data);
|
|
302
|
+
if (waitMs > 0) data.wait = { wake_at: now() + waitMs, wait_ms: waitMs };
|
|
303
|
+
append(id, 'judge_ran', data, 'judge');
|
|
304
|
+
if (!deps.skipReconcile) {
|
|
305
|
+
try { require('./campaign-rsi').writeRsiSnapshot(root, store, id); } catch {}
|
|
306
|
+
}
|
|
307
|
+
log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
|
|
308
|
+
return data;
|
|
309
|
+
}
|
|
310
|
+
|
|
189
311
|
// One step, with a sync on each side.
|
|
190
312
|
async function step(id) {
|
|
191
313
|
await sync(id);
|
|
@@ -205,29 +327,52 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
205
327
|
if (current.error) return { stop: true, reason: `campaign cannot be read: ${current.error}` };
|
|
206
328
|
const move = decide(current);
|
|
207
329
|
if (move.action === 'none') return { stop: true, reason: move.reason };
|
|
208
|
-
|
|
330
|
+
const needsYou = (n) => ({ stop: true, reason: `needs you: ${n} open ask${n === 1 ? '' : 's'}` });
|
|
331
|
+
if (move.action === 'wait' && move.reason === 'open asks') return needsYou(move.asks.length);
|
|
332
|
+
// A protected lane waiting on a person holds the whole campaign: the loop
|
|
333
|
+
// cannot tell which next plan would touch that lane again.
|
|
334
|
+
if (openLaneAsks(store.read(id)).size) return needsYou(current.asks.length);
|
|
335
|
+
// A saved wait is a promise: nothing runs before it is due, even in a new
|
|
336
|
+
// process. When it is due, only the judge runs. The same reading waits
|
|
337
|
+
// twice as long; a changed one lets the next step plan again.
|
|
338
|
+
const saved = current.next_move;
|
|
339
|
+
if (saved && saved.action === 'wait') {
|
|
340
|
+
if (now() < saved.wake_at) return { stop: true, reason: `waiting on the world: next check at ${clockTime(saved.wake_at)}` };
|
|
341
|
+
const before = current.last_judge ? readingKey(current.last_judge.passed, current.last_judge.reading, current.answer.text) : null;
|
|
342
|
+
const data = await readJudge(id, current, (d) => readingKey(d.passed, d.reading, d.summary) === before ? Math.min(saved.wait_ms * 2, WAIT_MAX_MS) : 0);
|
|
343
|
+
if (data.wait) return { stop: true, reason: `nothing changed: next check at ${clockTime(data.wait.wake_at)}` };
|
|
344
|
+
return { stop: false, did: 'judge' };
|
|
345
|
+
}
|
|
209
346
|
if (move.action === 'run_judge') {
|
|
210
|
-
|
|
211
|
-
append(id, 'judge_ran', judgeRanData(reading), 'judge');
|
|
212
|
-
log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
|
|
347
|
+
await readJudge(id, current);
|
|
213
348
|
return { stop: false, did: 'judge' };
|
|
214
349
|
}
|
|
215
350
|
if (move.action === 'start_mission' || (move.action === 'wait' && move.reason === 'budget')) {
|
|
216
351
|
const work = claimedWork(id).slice(0, parallel);
|
|
217
|
-
if (!work.length) return { stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
|
|
352
|
+
if (!work.length) return { ok: false, stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
|
|
218
353
|
log(`starting ${work.length} mission${work.length === 1 ? '' : 's'} at once`);
|
|
219
354
|
const results = await Promise.all(work.map((w) => startAndRun(id, w)));
|
|
220
|
-
if (!results.some((r) => r.started)) return { stop: true, reason: 'no claimed mission could start' };
|
|
355
|
+
if (!results.some((r) => r.started)) return { ok: false, stop: true, reason: 'no claimed mission could start' };
|
|
221
356
|
return { stop: false, did: 'missions' };
|
|
222
357
|
}
|
|
223
358
|
if (move.action === 'wait') return { stop: true, reason: `waiting: ${move.reason} (started elsewhere)` };
|
|
224
359
|
log(`asking ${current.owner} to propose up to ${parallel} missions`);
|
|
225
360
|
const proposals = await propose(id, parallel);
|
|
226
|
-
if (!proposals.length)
|
|
361
|
+
if (!proposals.length) {
|
|
362
|
+
const data = await readJudge(id, current, () => WAIT_FIRST_MS);
|
|
363
|
+
const next = data.wait ? `; next check at ${clockTime(data.wait.wake_at)}` : '';
|
|
364
|
+
return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}` };
|
|
365
|
+
}
|
|
227
366
|
for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
|
|
228
367
|
return { stop: false, did: 'propose' };
|
|
229
368
|
}
|
|
230
369
|
|
|
370
|
+
// One tick: the one step that is due now, with a sync on each side. A timer
|
|
371
|
+
// or a woken process calls this; run() is for a person watching.
|
|
372
|
+
async function tick(id) {
|
|
373
|
+
return step(id);
|
|
374
|
+
}
|
|
375
|
+
|
|
231
376
|
async function run(id, maxSteps = 20) {
|
|
232
377
|
await sync(id);
|
|
233
378
|
for (let i = 0; i < maxSteps; i++) {
|
|
@@ -240,7 +385,7 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
240
385
|
return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
|
|
241
386
|
}
|
|
242
387
|
|
|
243
|
-
return { step, run, propose };
|
|
388
|
+
return { step, tick, run, propose };
|
|
244
389
|
}
|
|
245
390
|
|
|
246
|
-
module.exports = { askOwner, makeLoop, parseProposals, proposalPrompt };
|
|
391
|
+
module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readingKey, runCli };
|
package/lib/campaign-protect.js
CHANGED
|
@@ -29,6 +29,12 @@ function protectFile(home, campaignId) {
|
|
|
29
29
|
return path.join(home, 'campaigns', campaignId, 'protect.json');
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// The commit a checkout sits on, or null outside git or before the first commit.
|
|
33
|
+
function headCommit(root) {
|
|
34
|
+
const head = git(root, ['rev-parse', 'HEAD']);
|
|
35
|
+
return head.ok ? head.out : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
// The commit and checkout-relative paths to save, or an error in plain words.
|
|
33
39
|
function planProtect(root, paths) {
|
|
34
40
|
const topDir = checkoutTop(root);
|
|
@@ -72,4 +78,4 @@ function changedProtected(root, saved) {
|
|
|
72
78
|
return { changed: [...new Set(changed)].sort() };
|
|
73
79
|
}
|
|
74
80
|
|
|
75
|
-
module.exports = { campaignHome, changedProtected, checkoutTop, planProtect, protectFile, readProtect, saveProtect };
|
|
81
|
+
module.exports = { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, protectFile, readProtect, saveProtect };
|