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/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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
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 === '
|
|
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
|
};
|
package/commands/worktree.js
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|
package/lib/campaign-cadence.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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(
|
|
32
|
-
return text.split('\n').filter((line) => line.trim().startsWith('- '))
|
|
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
|
|
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
|
|
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}: ${
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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.
|
package/lib/campaign-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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 };
|