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/commands/pulse.js CHANGED
@@ -53,6 +53,9 @@ Options:
53
53
  --cadence "<cron>" Cron cadence for install
54
54
  --days <n> Auto-expire installed heartbeat after n days
55
55
  --hours <n> Auto-expire installed heartbeat after n hours
56
+ --no-keep-awake Install without holding the Mac awake for the window (macOS)
57
+ --no-autopilot install/tick: never fall back to autopilot (only campaigns and due missions)
58
+ --no-campaigns tick: skip pushing campaigns (atris campaign tick)
56
59
  --model <id> Runner model alias/id for installed heartbeat
57
60
  --runner-profile <n> Runner profile for installed heartbeat (e.g. atris-fast)
58
61
  --runner-bin <path> Runner binary for installed heartbeat
@@ -128,9 +131,43 @@ function runAutopilotTick(root, { timeoutMs = 600000 } = {}) {
128
131
  };
129
132
  }
130
133
 
131
- // The full heartbeat composition: continue a due mission, else author+pursue a
132
- // new goal via autopilot. This is the /loop skill's logic, encoded for OS cron.
133
- function runEngine(root, { noClaude = false, autopilotFallback = true, timeoutMs = 600000 } = {}) {
134
+ // Campaigns first: one bounded push on the next campaign here that has work
135
+ // (atris campaign tick). A campaign mission can outlast a mission tick, so it gets
136
+ // up to 50 minutes; the campaign run lock keeps two drivers off one campaign.
137
+ const CAMPAIGN_TICK_MS = 50 * 60 * 1000;
138
+ function runCampaignEngine(root, { timeoutMs = CAMPAIGN_TICK_MS } = {}) {
139
+ const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
140
+ const result = spawnSync(process.execPath, [cliPath, 'campaign', 'tick', '--json'], {
141
+ cwd: root,
142
+ encoding: 'utf8',
143
+ timeout: timeoutMs,
144
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
145
+ });
146
+ let payload = null;
147
+ try {
148
+ payload = JSON.parse(result.stdout || '{}');
149
+ } catch {}
150
+ const idle = Boolean(payload && payload.picked === null);
151
+ return {
152
+ actor: 'campaign_tick',
153
+ ok: result.status === 0,
154
+ reason: idle ? 'no_campaign_work' : (payload && payload.reason) || (result.status === 0 ? 'advanced' : 'error'),
155
+ idle,
156
+ status: result.status,
157
+ payload,
158
+ stdout: String(result.stdout || '').slice(-2000),
159
+ stderr: String(result.stderr || '').slice(-2000),
160
+ };
161
+ }
162
+
163
+ // The full heartbeat composition: push a campaign that has work, else continue a
164
+ // due mission, else author+pursue a new goal via autopilot. This is the /loop
165
+ // skill's logic, encoded for OS cron.
166
+ function runEngine(root, { noClaude = false, autopilotFallback = true, campaigns = true, timeoutMs = 600000 } = {}) {
167
+ if (campaigns && !noClaude) {
168
+ const campaign = runCampaignEngine(root);
169
+ if (!campaign.idle) return campaign;
170
+ }
134
171
  const mission = runMissionEngine(root, { noClaude, timeoutMs });
135
172
  if (pulse.shouldFallbackToAutopilot({ missionReason: mission.reason, autopilotFallback, noClaude })) {
136
173
  const ap = runAutopilotTick(root, { timeoutMs });
@@ -284,7 +321,7 @@ function tickCommand(args, root = process.cwd()) {
284
321
  // filesystem snapshot when git is absent.
285
322
  const isRepo = fs.existsSync(path.join(root, '.git'));
286
323
  const before = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
287
- engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot') });
324
+ engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot'), campaigns: !hasFlag(args, '--no-campaigns') });
288
325
  const after = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
289
326
  // This tick's ACTUAL contribution: files it newly dirtied, or a new commit.
290
327
  // Pre-existing dirt is excluded so reward isn't re-credited every tick.
@@ -315,7 +352,9 @@ function tickCommand(args, root = process.cwd()) {
315
352
  const changedTail = committed
316
353
  ? ' - committed'
317
354
  : (changedFiles.length ? ` - ${changedFiles.length} file(s) changed` : '');
318
- const what = engine.actor === 'autopilot'
355
+ const what = engine.actor === 'campaign_tick'
356
+ ? `campaign ${(engine.payload && engine.payload.campaign && engine.payload.campaign.id) || ''} ${engine.reason}${changedTail}`.replace(/\s+/g, ' ')
357
+ : engine.actor === 'autopilot'
319
358
  ? `autopilot authored/advanced a goal${changedTail}`
320
359
  : engine.reason === 'no_due_mission'
321
360
  ? 'no due mission; heartbeat alive (no-op)'
@@ -677,6 +716,8 @@ function installCommand(args, root = process.cwd(), options = {}) {
677
716
  pathDirs,
678
717
  launchLabel: label,
679
718
  launchPlistPath: plistPath,
719
+ // Baked into every tick: a night of campaigns only, no invented goals.
720
+ tickFlags: ['--no-autopilot', '--no-campaigns'].filter((flag) => hasFlag(args, flag)),
680
721
  });
681
722
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
682
723
 
@@ -695,6 +736,10 @@ function installCommand(args, root = process.cwd(), options = {}) {
695
736
  const apply = commandSpawn('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8', timeout: 10000 });
696
737
  const expiryBreadcrumbCleared = apply.status === 0 && clearPulseExpiryBreadcrumb(root);
697
738
  const cadenceMapping = pulse.launchIntervalFromCadence({ cron });
739
+ // Tests pass their own spawnSync and never get a real caffeinate unless they ask.
740
+ const wantsKeepAwake = apply.status === 0 && process.platform === 'darwin' && !hasFlag(args, '--no-keep-awake')
741
+ && (!options.spawnSync || options.holdKeepAwake);
742
+ const keepAwake = wantsKeepAwake ? (options.holdKeepAwake || holdKeepAwake)(slot.stateHome, expiry.seconds) : null;
698
743
 
699
744
  const out = {
700
745
  ok: apply.status === 0,
@@ -718,6 +763,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
718
763
  runner_bin: runnerBin || null,
719
764
  runner_template_configured: Boolean(runnerCommandTemplate),
720
765
  expiry_breadcrumb_cleared: expiryBreadcrumbCleared,
766
+ keep_awake: keepAwake,
721
767
  };
722
768
  if (!asJson) {
723
769
  if (apply.status === 0) {
@@ -728,6 +774,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
728
774
  `script: ${scriptPath}`,
729
775
  cadenceMapping.fallback ? `cadence '${cron}' is not directly mappable; using 3600 seconds.` : '',
730
776
  `auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
777
+ ...(keepAwake ? [`keeps this Mac awake until then (lid open); atris pulse uninstall lets it sleep.`] : []),
731
778
  ].filter(Boolean).join('\n') + '\n');
732
779
  } else {
733
780
  writeOutput(`pulse install failed to bootstrap ${label}: ${apply.stderr || apply.status}\nplist: ${plistPath}\n`);
@@ -736,6 +783,40 @@ function installCommand(args, root = process.cwd(), options = {}) {
736
783
  return emit(out, asJson);
737
784
  }
738
785
 
786
+ // The heartbeat only ticks while the Mac is awake. On macOS an overnight install
787
+ // holds a no-idle-sleep assertion (caffeinate -i) for exactly the heartbeat's
788
+ // window, so a Mac on its own power settings does not sleep through the night.
789
+ // Closing the lid still sleeps. Uninstall lets it go. --no-keep-awake skips it.
790
+ function keepAwakeFile(stateHome) {
791
+ return path.join(stateHome, 'keep-awake.pid');
792
+ }
793
+
794
+ function releaseKeepAwake(stateHome) {
795
+ const file = keepAwakeFile(stateHome);
796
+ let pid = 0;
797
+ try { pid = Number(fs.readFileSync(file, 'utf8').trim()); } catch { return false; }
798
+ fs.rmSync(file, { force: true });
799
+ if (!Number.isInteger(pid) || pid <= 0) return false;
800
+ try {
801
+ const name = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).stdout.trim();
802
+ if (!/caffeinate$/.test(name)) return false;
803
+ process.kill(pid, 'SIGTERM');
804
+ return true;
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+
810
+ function holdKeepAwake(stateHome, seconds) {
811
+ releaseKeepAwake(stateHome);
812
+ const { spawn } = require('child_process');
813
+ const child = spawn('caffeinate', ['-i', '-t', String(Math.max(60, Math.floor(seconds)))], { detached: true, stdio: 'ignore' });
814
+ child.unref();
815
+ fs.mkdirSync(stateHome, { recursive: true });
816
+ fs.writeFileSync(keepAwakeFile(stateHome), `${child.pid}\n`);
817
+ return { pid: child.pid, seconds: Math.floor(seconds) };
818
+ }
819
+
739
820
  function uninstallCommand(args, root = process.cwd(), options = {}) {
740
821
  const asJson = wantsJson(args);
741
822
  const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
@@ -745,6 +826,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
745
826
  const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
746
827
  const uid = options.uid != null ? options.uid : process.getuid();
747
828
  commandSpawn('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 10000 });
829
+ const keepAwakeReleased = (options.releaseKeepAwake || releaseKeepAwake)(slot.stateHome || slot.activeStateHome);
748
830
  const plistHad = fs.existsSync(plistPath);
749
831
  fs.rmSync(plistPath, { force: true });
750
832
  const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
@@ -757,6 +839,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
757
839
  ok: apply.status === 0,
758
840
  action: 'pulse_uninstall',
759
841
  removed: plistHad || had,
842
+ keep_awake_released: keepAwakeReleased,
760
843
  label,
761
844
  plist_path: plistPath,
762
845
  removed_legacy_crontab: had,
@@ -818,5 +901,6 @@ module.exports = {
818
901
  pulseExpiryBreadcrumbPath,
819
902
  installCommand,
820
903
  runCommand,
904
+ uninstallCommand,
821
905
  refreshOrbPolicyLessons,
822
906
  };
@@ -16,6 +16,9 @@ const close = require('./close');
16
16
  // parallel landings that each carry a copy always clash.
17
17
  const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
18
18
  const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
19
+ // Push also runs local hooks. Backend startup and regression checks alone can
20
+ // exceed the short network-only allowance used by fetch and discovery.
21
+ const GIT_PUSH_TIMEOUT_MS = 5 * 60 * 1000;
19
22
  const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
20
23
  const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
21
24
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -37,13 +40,15 @@ function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
37
40
  // A killed network call leaves stderr empty; name the timeout instead.
38
41
  function gitFailureDetail(result) {
39
42
  if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
40
- return String((result && (result.stderr || result.stdout)) || '').trim();
43
+ return [result && result.stdout, result && result.stderr]
44
+ .map(output => String(output || '').trim()).filter(Boolean).join('\n');
41
45
  }
42
46
 
43
47
  function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
44
48
  const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
45
49
  // Only network calls need the SSH probe; local calls skip its extra git spawn.
46
- const options = { cwd, encoding: 'utf8', timeout: timeout ?? (network ? 30000 : undefined),
50
+ const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
51
+ const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
47
52
  killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
48
53
  let result = spawnSync('git', args, options);
49
54
  // Linked checkouts share config. A concurrent start must not silently lose
@@ -1118,6 +1123,23 @@ function shipWorktree(args) {
1118
1123
  if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
1119
1124
  throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
1120
1125
  }
1126
+ // A merge queue accepts the request before merging. Deleting its head
1127
+ // here closes the PR and cancels that request, so confirm the outcome.
1128
+ const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt'], { cwd: root, encoding: 'utf8' });
1129
+ if (confirmation.status !== 0) {
1130
+ throw new Error('could not confirm the merge; the source branch was kept');
1131
+ }
1132
+ let state;
1133
+ try { state = JSON.parse(confirmation.stdout); } catch {
1134
+ throw new Error('could not read the merge result; the source branch was kept');
1135
+ }
1136
+ if (state.state === 'OPEN' && !state.mergedAt) {
1137
+ console.log('merge: pending; kept the source branch while required checks finish');
1138
+ return 0;
1139
+ }
1140
+ if (state.state !== 'MERGED' || !state.mergedAt) {
1141
+ throw new Error('the change was not merged; the source branch was kept');
1142
+ }
1121
1143
  console.log('merge: merged');
1122
1144
  const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
1123
1145
  if (deleted.status === 0) {
@@ -9,7 +9,9 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { paysDollars } = require('./campaign-core');
12
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
14
+ const { readingKey } = require('./campaign-loop');
13
15
 
14
16
  const LESSON_LINES_FOR_PLANNING = 30;
15
17
 
@@ -25,16 +27,35 @@ function lessonsPath(root, campaignId) {
25
27
  return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
26
28
  }
27
29
 
28
- // The newest lessons, for the planner.
29
- function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
30
+ function globalLessonsPath(root) {
31
+ return path.join(root, 'atris', 'campaigns', 'lessons.md');
32
+ }
33
+
34
+ function parseBulletLines(filePath) {
30
35
  try {
31
- const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
32
- return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
36
+ const text = fs.readFileSync(filePath, 'utf8');
37
+ return text.split('\n').filter((line) => line.trim().startsWith('- '));
33
38
  } catch {
34
- return '';
39
+ return [];
35
40
  }
36
41
  }
37
42
 
43
+ // The newest lessons for the planner: local campaign lessons plus deduplicated
44
+ // cross-campaign RSI lessons so every new campaign inherits earlier lessons.
45
+ function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
46
+ const local = parseBulletLines(lessonsPath(root, campaignId));
47
+ const global = parseBulletLines(globalLessonsPath(root));
48
+ const seen = new Set();
49
+ const merged = [];
50
+ for (const line of [...local, ...global]) {
51
+ const key = line.trim().toLowerCase();
52
+ if (seen.has(key)) continue;
53
+ seen.add(key);
54
+ merged.push(line);
55
+ }
56
+ return merged.slice(-lines).join('\n');
57
+ }
58
+
38
59
  function roundSummary(before, after) {
39
60
  const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
40
61
  const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
@@ -44,8 +65,20 @@ function roundSummary(before, after) {
44
65
  return { kept, failed, vetoed, spent, fresh };
45
66
  }
46
67
 
68
+ // Nothing finished and the check reads the same: there is nothing to learn.
69
+ function quietRound(before, after, summary) {
70
+ const key = (v) => (v.last_judge ? readingKey(v.last_judge.passed, v.last_judge.reading, v.answer.text) : null);
71
+ return !summary.fresh.some((m) => m.status === 'ended' || m.status === 'vetoed') && key(before) === key(after);
72
+ }
73
+
74
+ // On a subscription the round is told without dollars, since none were paid.
75
+ function roundTally(view, summary) {
76
+ return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
77
+ }
78
+
47
79
  function reviewPrompt(view, summary, lessons) {
48
- const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
80
+ const paid = paysDollars(view);
81
+ const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${paid && m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
49
82
  return [
50
83
  'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
51
84
  'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
@@ -53,7 +86,7 @@ function reviewPrompt(view, summary, lessons) {
53
86
  `Campaign: ${view.title}`,
54
87
  `Judge: ${view.judge}`,
55
88
  `Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
56
- `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
89
+ `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start${paid ? `, $${summary.spent} spent` : ''}`,
57
90
  'Missions this round:',
58
91
  missions,
59
92
  '',
@@ -75,9 +108,10 @@ function makeCadence(root, loop, options = {}, deps = {}) {
75
108
  async function review(id, before) {
76
109
  const after = getCampaign(store, id);
77
110
  const summary = roundSummary(before, after);
111
+ const quiet = quietRound(before, after, summary);
78
112
  const lessons = readLessons(root, id, 200);
79
113
  let written = [];
80
- if (ask) {
114
+ if (ask && !quiet) {
81
115
  const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
82
116
  // A lesson already on file is not written twice.
83
117
  const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
@@ -88,9 +122,11 @@ function makeCadence(root, loop, options = {}, deps = {}) {
88
122
  fs.mkdirSync(path.dirname(file), { recursive: true });
89
123
  if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
90
124
  const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
91
- const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
92
- fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
93
- return { summary, lessons: written };
125
+ const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
126
+ // A quiet round is noted as plain text, so the planner never reads it as a lesson.
127
+ const body = quiet ? ['(no review written this round: nothing ended and the reading held)'] : (written.length ? written : ['- (no review written this round)']);
128
+ fs.appendFileSync(file, `${header}${body.join('\n')}\n`);
129
+ return { summary, lessons: written, view: after, quiet };
94
130
  }
95
131
 
96
132
  async function run(id) {
@@ -111,8 +147,8 @@ function makeCadence(root, loop, options = {}, deps = {}) {
111
147
  }
112
148
  const breakStarted = now();
113
149
  log(`round ${round}: review`);
114
- const { summary, lessons } = await review(id, before);
115
- log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
150
+ const { summary, lessons, view: reviewed, quiet } = await review(id, before);
151
+ log(quiet ? ' nothing new this round; no review' : ` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
116
152
  const view = getCampaign(store, id);
117
153
  last = { reason: stopped || 'work window ended', round };
118
154
  // Finished, out of money, or waiting on a person: no point in another round.
@@ -60,7 +60,35 @@ function newMission(id) {
60
60
  }
61
61
 
62
62
  // Returns the folded view, or { error } when the events must be refused.
63
+ // A judge reading can carry a saved wait: nothing to do until wake_at, then
64
+ // check the judge again. It rides as extra data on judge_ran so a reader that
65
+ // predates it still folds the campaign. Anything new (a mission, an answer, a
66
+ // new judge, more money, a resume) ends the wait.
67
+ const WAIT_ENDS = new Set(['mission_proposed', 'mission_claimed', 'mission_started', 'mission_ended', 'judge_set', 'ask_answered', 'resumed', 'budget_added']);
68
+
69
+ function savedWait(data) {
70
+ const w = data.wait;
71
+ if (data.passed || !w || !Number.isFinite(w.wake_at) || !(w.wait_ms > 0)) return null;
72
+ return { wake_at: w.wake_at, wait_ms: w.wait_ms };
73
+ }
74
+
63
75
  function fold(events) {
76
+ return foldEvents(events);
77
+ }
78
+
79
+ // Internal consumers can project selected canonical prefix counts in the same
80
+ // traversal. The synchronous projector is trusted and read-only; only its
81
+ // result is retained, so callers need not keep every full historical view.
82
+ function foldWithPrefixes(events, prefixLengths, project = (view) => view) {
83
+ const requested = new Set(prefixLengths.filter((n) => Number.isSafeInteger(n) && n >= 1));
84
+ const prefixes = new Map();
85
+ const view = foldEvents(events, (state, start, owner, count) => {
86
+ if (requested.has(count)) prefixes.set(count, project(renderView(state, start, owner), count));
87
+ });
88
+ return view.error ? view : { view, prefixes };
89
+ }
90
+
91
+ function foldEvents(events, visit) {
64
92
  if (!Array.isArray(events)) return { error: 'invalid_event' };
65
93
  for (const event of events) {
66
94
  if (!validateEvent(event).ok) return { error: 'invalid_event' };
@@ -94,9 +122,12 @@ function fold(events) {
94
122
  asks: new Map(),
95
123
  judgeRuns: [],
96
124
  lastEndedPos: -1,
125
+ wait: null,
97
126
  judgeSetPos: -1,
98
127
  judge: start.data.judge,
99
128
  lastJudgePos: -1,
129
+ // Jobs that did real work and ended since the last reading, waiting on it.
130
+ awaitingReading: [],
100
131
  warnings: [],
101
132
  };
102
133
  const d = start.data;
@@ -113,6 +144,7 @@ function fold(events) {
113
144
  const sequence = [start, ...ordered.filter((e) => e !== start)];
114
145
  sequence.forEach((event, pos) => {
115
146
  const data = event.data;
147
+ if (WAIT_ENDS.has(event.type)) s.wait = null;
116
148
  switch (event.type) {
117
149
  case 'campaign_started':
118
150
  if (event !== start) warn(event, 'duplicate_start');
@@ -129,6 +161,7 @@ function fold(events) {
129
161
  } else {
130
162
  s.judge = data.judge;
131
163
  s.judgeSetPos = pos;
164
+ s.awaitingReading = [];
132
165
  }
133
166
  break;
134
167
  case 'attached':
@@ -181,6 +214,8 @@ function fold(events) {
181
214
  const m = mission(data.mission_id);
182
215
  m.status = 'ended';
183
216
  m.outcome = data.outcome;
217
+ if (data.outcome === 'fault' && typeof data.reason === 'string') m.reason = data.reason;
218
+ if (m.steps > 0 && data.outcome !== 'kept' && data.outcome !== 'fault') s.awaitingReading.push(m);
184
219
  m.costCents = cents(data.cost_usd);
185
220
  s.spentCents += m.costCents;
186
221
  s.reservations.delete(data.mission_id);
@@ -207,6 +242,11 @@ function fold(events) {
207
242
  at: event.at,
208
243
  });
209
244
  s.lastJudgePos = pos;
245
+ s.wait = savedWait(data);
246
+ // A job that worked but stopped short is credited when the very next
247
+ // reading passes: its work is what moved the check.
248
+ if (data.passed) for (const m of s.awaitingReading) m.credited = true;
249
+ s.awaitingReading = [];
210
250
  if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
211
251
  break;
212
252
  case 'ask_opened':
@@ -236,8 +276,14 @@ function fold(events) {
236
276
  default:
237
277
  break;
238
278
  }
279
+ if (visit) visit(s, start, owner, pos + 1);
239
280
  });
240
281
 
282
+ return renderView(s, start, owner);
283
+ }
284
+
285
+ function renderView(s, start, owner) {
286
+ const d = start.data;
241
287
  const missions = [...s.missions.values()];
242
288
  let state;
243
289
  let stopReason = null;
@@ -279,11 +325,14 @@ function fold(events) {
279
325
  judge: s.judge,
280
326
  owner,
281
327
  standing: d.standing === true,
282
- bet: s.bet,
328
+ bet: s.bet ? { ...s.bet } : null,
283
329
  parent: s.parent,
284
330
  home: s.home,
331
+ // Only when the start event names one, so the shared golden views stay as
332
+ // pinned; no basis means the person's own engines.
333
+ ...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
285
334
  created_from: d.created_from === undefined ? null : d.created_from,
286
- seats: s.seats,
335
+ seats: s.seats.map((seat) => ({ ...seat })),
287
336
  state,
288
337
  stop_reason: stopReason,
289
338
  answer: { text: latest ? latest.summary : null, confidence },
@@ -293,7 +342,9 @@ function fold(events) {
293
342
  reserved: dollars(reservedCents),
294
343
  available: dollars(s.totalCents - s.spentCents - reservedCents),
295
344
  },
296
- asks: [...s.asks.values()],
345
+ asks: [...s.asks.values()].map((ask) => ({ ...ask })),
346
+ // Only while a wait is saved, so the shared golden views stay as pinned.
347
+ ...(s.wait ? { next_move: { action: 'wait', reason: 'waiting on the world', ...s.wait } } : {}),
297
348
  missions: missions.map((m) => ({
298
349
  id: m.id,
299
350
  member: m.member,
@@ -304,11 +355,13 @@ function fold(events) {
304
355
  steps: m.steps,
305
356
  kept: m.kept,
306
357
  reverted: m.reverted,
358
+ ...(m.reason ? { reason: m.reason } : {}),
359
+ ...(m.credited ? { credited: true } : {}),
307
360
  })),
308
361
  judge_runs: runs.length,
309
362
  judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
310
363
  last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
311
- warnings: s.warnings,
364
+ warnings: s.warnings.map((warning) => ({ ...warning })),
312
365
  };
313
366
  }
314
367
 
@@ -316,9 +369,13 @@ function decide(view) {
316
369
  if (view.state === 'done') return { action: 'none', reason: 'finished' };
317
370
  if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
318
371
  if (view.state === 'paused') return { action: 'none', reason: 'paused' };
319
- if (view.asks.length > 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
372
+ // An open ask holds only the mission it names. When every claimed mission is
373
+ // held, the campaign waits for the person; an ask that names no mission holds nothing.
374
+ const held = new Set(view.asks.map((a) => a.mission_id).filter(Boolean));
375
+ const queued = view.missions.filter((m) => m.status === 'claimed');
376
+ const claimed = queued.filter((m) => !held.has(m.id));
377
+ if (queued.length > 0 && claimed.length === 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
320
378
  if (view.judge_due) return { action: 'run_judge' };
321
- const claimed = view.missions.filter((m) => m.status === 'claimed');
322
379
  const available = cents(view.budget.available);
323
380
  const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
324
381
  if (fits) return { action: 'start_mission', mission_id: fits.id };
@@ -327,4 +384,12 @@ function decide(view) {
327
384
  return { action: 'propose', reason: 'no missions queued' };
328
385
  }
329
386
 
330
- module.exports = { cents, compareEvents, decide, fold };
387
+ // Spend is real dollars only when billing is API or Atris runs the campaign on
388
+ // its own engines (basis atris_cloud). Otherwise the work ran on the person's own
389
+ // subscriptions: they paid nothing, and cost_usd stays as the API-price budget meter.
390
+ function paysDollars(view) {
391
+ if (process.env.ATRIS_BILLING === 'api') return true;
392
+ return Boolean(view && view.basis === 'atris_cloud');
393
+ }
394
+
395
+ module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, paysDollars };
@@ -0,0 +1,91 @@
1
+ // atris campaign keep: a one-minute timer that runs `atris campaign wake`,
2
+ // so a campaign's promise to check again holds while no terminal is open
3
+ // (atris/features/campaign/promise.md). launchd on macOS, cron elsewhere.
4
+
5
+ 'use strict';
6
+
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ const MARKER = 'ATRIS_CAMPAIGN_WAKE';
14
+
15
+ function xml(text) {
16
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
17
+ }
18
+
19
+ function shellQuote(text) {
20
+ return `'${String(text).replace(/'/g, `'\\''`)}'`;
21
+ }
22
+
23
+ // One timer per checkout: the label and marker carry a short hash of its root.
24
+ function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '' }) {
25
+ const slot = crypto.createHash('sha256').update(root).digest('hex').slice(0, 8);
26
+ // node by name on the saved PATH, so upgrading node does not break the timer.
27
+ const command = ['/usr/bin/env', nodeBin, atrisBin, 'campaign', 'wake', '--json'];
28
+ const logPath = path.join(home, 'campaign-wake.log');
29
+ const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: 60 };
30
+ if (platform === 'darwin') {
31
+ const label = `ai.atris.campaign-wake.${slot}`;
32
+ const file = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
33
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${xml(label)}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>${command.map((part) => `<string>${xml(part)}</string>`).join('')}</array>
40
+ <key>WorkingDirectory</key><string>${xml(root)}</string>
41
+ <key>EnvironmentVariables</key>
42
+ <dict><key>PATH</key><string>${xml(envPath)}</string><key>ATRIS_SKIP_UPDATE_CHECK</key><string>1</string></dict>
43
+ <key>StartInterval</key><integer>60</integer>
44
+ <key>StandardOutPath</key><string>${xml(logPath)}</string>
45
+ <key>StandardErrorPath</key><string>${xml(logPath)}</string>
46
+ </dict>
47
+ </plist>
48
+ `;
49
+ return { ...base, kind: 'launchd', label, file, contents };
50
+ }
51
+ const marker = `${MARKER}_${slot}`;
52
+ const line = `* * * * * cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
53
+ return { ...base, kind: 'cron', marker, crontab_line: line };
54
+ }
55
+
56
+ function withoutMarker(crontab, marker) {
57
+ return String(crontab || '').split('\n').filter((l) => l.trim() && !l.includes(marker)).join('\n');
58
+ }
59
+
60
+ // Replaces any timer this checkout already has. Returns { ok, error? }.
61
+ function installKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
62
+ fs.mkdirSync(path.dirname(plan.log_path), { recursive: true });
63
+ if (plan.kind === 'launchd') {
64
+ fs.mkdirSync(path.dirname(plan.file), { recursive: true });
65
+ fs.writeFileSync(plan.file, plan.contents, 'utf8');
66
+ spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
67
+ const r = spawn('launchctl', ['bootstrap', `gui/${uid}`, plan.file], { encoding: 'utf8', timeout: 10000 });
68
+ return r.status === 0 ? { ok: true } : { ok: false, error: `launchctl bootstrap failed: ${String(r.stderr || r.stdout || '').trim()}` };
69
+ }
70
+ const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
71
+ const kept = withoutMarker(existing.status === 0 ? existing.stdout : '', plan.marker);
72
+ const r = spawn('crontab', ['-'], { input: `${kept ? `${kept}\n` : ''}${plan.crontab_line}\n`, encoding: 'utf8', timeout: 10000 });
73
+ return r.status === 0 ? { ok: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
74
+ }
75
+
76
+ function removeKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
77
+ if (plan.kind === 'launchd') {
78
+ spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
79
+ const had = fs.existsSync(plan.file);
80
+ if (had) fs.unlinkSync(plan.file);
81
+ return { ok: true, removed: had };
82
+ }
83
+ const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
84
+ const prior = existing.status === 0 ? String(existing.stdout || '') : '';
85
+ if (!prior.includes(plan.marker)) return { ok: true, removed: false };
86
+ const kept = withoutMarker(prior, plan.marker);
87
+ const r = spawn('crontab', ['-'], { input: kept ? `${kept}\n` : '', encoding: 'utf8', timeout: 10000 });
88
+ return r.status === 0 ? { ok: true, removed: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
89
+ }
90
+
91
+ module.exports = { MARKER, installKeep, planKeep, removeKeep };