atris 3.33.3 → 3.34.0
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.md +17 -0
- package/bin/atris.js +10 -1
- package/commands/loops.js +212 -0
- package/commands/mission.js +45 -1
- package/lib/fleet.js +6 -4
- package/package.json +1 -1
package/atris.md
CHANGED
|
@@ -91,6 +91,23 @@ The same discipline for words. Output stays sharp no matter how bloated the cont
|
|
|
91
91
|
|
|
92
92
|
`expected`: this is how an Atris agent writes and builds. Shipping slop or rambling is a failure smell, same as drift or a stale task.
|
|
93
93
|
|
|
94
|
+
## the ladder
|
|
95
|
+
|
|
96
|
+
Six primitives, one sentence each. Every agent in every repo holds this model:
|
|
97
|
+
|
|
98
|
+
- **The Mission** (MISSION.md): the enduring purpose. Never ends. Sits at the top and helps members pick goals.
|
|
99
|
+
- **Goals** (endgame, member goals): direction derived from the Mission. A goal points; it does not commit.
|
|
100
|
+
- **A mission run** (`atris mission`): a goal that signed a contract: objective + owner + verifier + stop condition. Ends in hours or days. Any engine can tick it.
|
|
101
|
+
- **Tasks** (`atris task`): the smallest provable units. Receipts, then human accept.
|
|
102
|
+
- **Members** (`atris member`): the WHO. A role with memory and judgment doing one bounded, proven slice at a time.
|
|
103
|
+
- **Loops** (`atris loops`): the WHEN. Clocks that never finish, only fire. Health is exit 0 on cadence.
|
|
104
|
+
|
|
105
|
+
In one line: the Mission anchors, goals point, a mission run commits, tasks prove, accepts land; loops fire members to tick mission runs.
|
|
106
|
+
|
|
107
|
+
Never conflate the two things called "mission": the Mission is permanent purpose; a mission run is a bounded commitment. An agent that mistakes a run for the Mission never stops working on it; one that mistakes the Mission for a run tries to complete it.
|
|
108
|
+
|
|
109
|
+
Drift smells: a member goal tracing to no Mission is freelancing; a goal that never becomes a mission run is a wish; a run with no goal above it is busywork; a loop deciding what "done" means owns the wrong axis.
|
|
110
|
+
|
|
94
111
|
## task source of truth
|
|
95
112
|
|
|
96
113
|
Use `atris task` as the source of truth for active work. It stores durable local
|
package/bin/atris.js
CHANGED
|
@@ -594,6 +594,7 @@ function showHelp() {
|
|
|
594
594
|
console.log(' console - Start/attach always-on coding console (tmux daemon)');
|
|
595
595
|
console.log(' soul - Show, snapshot, or fork workspace identity');
|
|
596
596
|
console.log(' fleet - Inspect local fleet status');
|
|
597
|
+
console.log(' loops - Background loops board: what runs, what died, start/stop');
|
|
597
598
|
console.log(' agent - Select cloud agent, spawn worker requests, or run `agent doctor`');
|
|
598
599
|
console.log(' chat - Chat with Atris 2 Fast in this workspace (--agent for cloud agent; or: atris chat scan)');
|
|
599
600
|
console.log(' fast - Chat with Atris2 Fast');
|
|
@@ -975,7 +976,7 @@ const knownCommands = ['init', 'log', 'now', 'radar', 'ctop', 'launchpad', 'stat
|
|
|
975
976
|
'clean', 'harvest', 'verify', 'search', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'plugin', 'experiments', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
|
|
976
977
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'land', 'autoland', 'aeo', 'slop', 'strings', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'xp', 'play', 'gm', 'x', 'recap', 'signup', 'clarity', 'moves',
|
|
977
978
|
'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
978
|
-
'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines'];
|
|
979
|
+
'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'loops', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines'];
|
|
979
980
|
|
|
980
981
|
// Check if command is an atris.md spec file - triggers welcome visualization
|
|
981
982
|
function isSpecFile(cmd) {
|
|
@@ -2267,6 +2268,14 @@ if (command === 'init') {
|
|
|
2267
2268
|
require('../commands/fleet').fleet(args)
|
|
2268
2269
|
.then(() => process.exit(0))
|
|
2269
2270
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2271
|
+
} else if (command === 'loops') {
|
|
2272
|
+
try {
|
|
2273
|
+
require('../commands/loops').loopsCommand(process.argv[3], ...process.argv.slice(4));
|
|
2274
|
+
process.exit(0);
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
console.error(`\n✗ Error: ${error.message || error}`);
|
|
2277
|
+
process.exit(1);
|
|
2278
|
+
}
|
|
2270
2279
|
} else if (command === 'spaceship') {
|
|
2271
2280
|
const args = process.argv.slice(3);
|
|
2272
2281
|
require('../commands/spaceship').spaceship(args)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atris loops - one view of every background loop: what runs, what died,
|
|
3
|
+
* what you can start or stop.
|
|
4
|
+
*
|
|
5
|
+
* atris loops - the board (registry jobs, launchd agents, missions)
|
|
6
|
+
* atris loops stop <id> - disable a registry job or boot out a launchd agent
|
|
7
|
+
* atris loops start <id> - enable a registry job or kickstart a launchd agent
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { execSync, spawnSync } = require('child_process');
|
|
14
|
+
|
|
15
|
+
function heartbeatDir() {
|
|
16
|
+
return process.env.ATRIS_HEARTBEAT_DIR || path.join(os.homedir(), '.atris', 'heartbeat');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readJson(filePath, fallback) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function registryPath() {
|
|
28
|
+
return path.join(heartbeatDir(), 'registry.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function loadRegistry() {
|
|
32
|
+
const registry = readJson(registryPath(), null);
|
|
33
|
+
return registry && Array.isArray(registry.jobs) ? registry : { jobs: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadRunState() {
|
|
37
|
+
return readJson(path.join(heartbeatDir(), 'state.json'), {});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function agoText(iso) {
|
|
41
|
+
const then = Date.parse(iso || '');
|
|
42
|
+
if (!Number.isFinite(then)) return 'never';
|
|
43
|
+
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
|
|
44
|
+
if (mins < 60) return `${mins}m ago`;
|
|
45
|
+
const hours = Math.floor(mins / 60);
|
|
46
|
+
if (hours < 48) return `${hours}h ago`;
|
|
47
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function listLaunchdAgents() {
|
|
51
|
+
let raw = '';
|
|
52
|
+
try {
|
|
53
|
+
raw = execSync('launchctl list', { encoding: 'utf8', timeout: 10000 });
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
return raw
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter(line => line.includes('com.atris.'))
|
|
60
|
+
.map(line => {
|
|
61
|
+
const [pid, exitCode, label] = line.trim().split(/\s+/);
|
|
62
|
+
return {
|
|
63
|
+
label,
|
|
64
|
+
running: pid !== '-',
|
|
65
|
+
pid: pid !== '-' ? pid : null,
|
|
66
|
+
lastExit: exitCode,
|
|
67
|
+
};
|
|
68
|
+
})
|
|
69
|
+
.filter(agent => agent.label);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function workspaceMission(cwd) {
|
|
73
|
+
const goalState = readJson(path.join(cwd, '.atris', 'state', 'atris_goal.json'), null);
|
|
74
|
+
const goal = goalState && goalState.goal;
|
|
75
|
+
if (!goal || !goal.objective) return null;
|
|
76
|
+
const started = Date.parse(goal.created_at || '');
|
|
77
|
+
const elapsedMins = Number.isFinite(started) ? Math.round((Date.now() - started) / 60000) : null;
|
|
78
|
+
return {
|
|
79
|
+
id: goal.mission_id || '?',
|
|
80
|
+
status: goal.mission_status || '?',
|
|
81
|
+
objective: String(goal.objective),
|
|
82
|
+
elapsedMins,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function showBoard(cwd) {
|
|
87
|
+
const registry = loadRegistry();
|
|
88
|
+
const state = loadRunState();
|
|
89
|
+
|
|
90
|
+
console.log('background loops');
|
|
91
|
+
console.log('');
|
|
92
|
+
|
|
93
|
+
const enabled = registry.jobs.filter(job => job.enabled !== false && job.disabled !== true);
|
|
94
|
+
const disabledCount = registry.jobs.length - enabled.length;
|
|
95
|
+
console.log(`heartbeat registry (${enabled.length} live, ${disabledCount} retired)`);
|
|
96
|
+
if (!enabled.length) {
|
|
97
|
+
console.log(' none declared');
|
|
98
|
+
}
|
|
99
|
+
for (const job of enabled) {
|
|
100
|
+
const run = state[job.id] || {};
|
|
101
|
+
const fails = Number(run.consecutive_fails || 0);
|
|
102
|
+
const health = fails > 2 ? `${fails} consecutive fails` : fails > 0 ? `${fails} recent fail${fails === 1 ? '' : 's'}` : 'healthy';
|
|
103
|
+
const cadence = job.cadence_minutes >= 60 ? `${Math.round(job.cadence_minutes / 60)}h` : `${job.cadence_minutes}m`;
|
|
104
|
+
console.log(` ${String(job.id).padEnd(22)} every ${String(cadence).padEnd(5)} last ${agoText(run.last_run).padEnd(9)} ${health}`);
|
|
105
|
+
console.log(` ${''.padEnd(22)} ${String(job.purpose || '').slice(0, 84)}`);
|
|
106
|
+
}
|
|
107
|
+
console.log('');
|
|
108
|
+
|
|
109
|
+
const agents = listLaunchdAgents();
|
|
110
|
+
console.log(`launchd agents (${agents.length})`);
|
|
111
|
+
for (const agent of agents) {
|
|
112
|
+
const status = agent.running ? `running pid ${agent.pid}` : `loaded, last exit ${agent.lastExit}`;
|
|
113
|
+
console.log(` ${agent.label.padEnd(44)} ${status}`);
|
|
114
|
+
}
|
|
115
|
+
if (!agents.length) console.log(' none');
|
|
116
|
+
console.log('');
|
|
117
|
+
|
|
118
|
+
const mission = workspaceMission(cwd);
|
|
119
|
+
console.log('mission (this workspace)');
|
|
120
|
+
if (mission) {
|
|
121
|
+
const elapsed = mission.elapsedMins === null ? '' : ` · ${mission.elapsedMins}m in`;
|
|
122
|
+
console.log(` ${mission.id}`);
|
|
123
|
+
console.log(` ${mission.status}${elapsed} · ${mission.objective.slice(0, 80)}`);
|
|
124
|
+
} else {
|
|
125
|
+
console.log(' none active');
|
|
126
|
+
}
|
|
127
|
+
console.log('');
|
|
128
|
+
console.log('control: atris loops stop <id> · atris loops start <id>');
|
|
129
|
+
console.log('ids: registry job id, or a com.atris.* launchd label');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function saveRegistry(registry) {
|
|
133
|
+
const target = registryPath();
|
|
134
|
+
fs.copyFileSync(target, `${target}.bak`);
|
|
135
|
+
fs.writeFileSync(target, `${JSON.stringify(registry, null, 2)}\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function setRegistryJob(id, enabled) {
|
|
139
|
+
const registry = loadRegistry();
|
|
140
|
+
const job = registry.jobs.find(row => row.id === id);
|
|
141
|
+
if (!job) return false;
|
|
142
|
+
job.enabled = enabled;
|
|
143
|
+
if (enabled) {
|
|
144
|
+
delete job.disabled;
|
|
145
|
+
delete job.disabled_reason;
|
|
146
|
+
} else {
|
|
147
|
+
job.disabled = true;
|
|
148
|
+
job.disabled_reason = `stopped via atris loops ${new Date().toISOString().slice(0, 10)}`;
|
|
149
|
+
}
|
|
150
|
+
saveRegistry(registry);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function launchdControl(label, action) {
|
|
155
|
+
const uid = process.getuid();
|
|
156
|
+
if (action === 'stop') {
|
|
157
|
+
const result = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' });
|
|
158
|
+
return result.status === 0;
|
|
159
|
+
}
|
|
160
|
+
const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
161
|
+
if (!fs.existsSync(plist)) return false;
|
|
162
|
+
const bootstrap = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { encoding: 'utf8' });
|
|
163
|
+
if (bootstrap.status === 0) return true;
|
|
164
|
+
const kick = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], { encoding: 'utf8' });
|
|
165
|
+
return kick.status === 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function controlLoop(id, action) {
|
|
169
|
+
const target = String(id || '').trim();
|
|
170
|
+
if (!target) {
|
|
171
|
+
console.error(`Usage: atris loops ${action} <id>`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
if (target.startsWith('com.atris.')) {
|
|
175
|
+
const ok = launchdControl(target, action);
|
|
176
|
+
if (!ok) {
|
|
177
|
+
console.error(`Could not ${action} ${target}. Check: launchctl print gui/$(id -u)/${target}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
console.log(`${action === 'stop' ? 'Stopped' : 'Started'} ${target}.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const ok = setRegistryJob(target, action === 'start');
|
|
184
|
+
if (!ok) {
|
|
185
|
+
console.error(`No registry job named "${target}". Run: atris loops`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
console.log(`${action === 'stop' ? 'Disabled' : 'Enabled'} ${target} in the heartbeat registry (backup written).`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function loopsCommand(subcommand, ...args) {
|
|
192
|
+
switch (subcommand) {
|
|
193
|
+
case undefined:
|
|
194
|
+
case 'board':
|
|
195
|
+
case 'list':
|
|
196
|
+
showBoard(process.cwd());
|
|
197
|
+
break;
|
|
198
|
+
case 'stop':
|
|
199
|
+
controlLoop(args[0], 'stop');
|
|
200
|
+
break;
|
|
201
|
+
case 'start':
|
|
202
|
+
controlLoop(args[0], 'start');
|
|
203
|
+
break;
|
|
204
|
+
default:
|
|
205
|
+
console.log('Loop commands:');
|
|
206
|
+
console.log(' atris loops - board: registry jobs, launchd agents, mission');
|
|
207
|
+
console.log(' atris loops stop <id> - disable a registry job or stop a launchd agent');
|
|
208
|
+
console.log(' atris loops start <id> - enable a registry job or start a launchd agent');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { loopsCommand, showBoard };
|
package/commands/mission.js
CHANGED
|
@@ -1632,7 +1632,7 @@ function missionFromArgs(args) {
|
|
|
1632
1632
|
'--native-goal-objective',
|
|
1633
1633
|
'--visible-goal-status',
|
|
1634
1634
|
'--visible-goal-objective',
|
|
1635
|
-
], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal', '--take-goal-slot']).join(' ').trim();
|
|
1635
|
+
], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--duplicate', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal', '--take-goal-slot']).join(' ').trim();
|
|
1636
1636
|
if (!objective) {
|
|
1637
1637
|
exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
|
|
1638
1638
|
}
|
|
@@ -2778,9 +2778,53 @@ function inheritedWorktreeBase(cwd) {
|
|
|
2778
2778
|
}
|
|
2779
2779
|
}
|
|
2780
2780
|
|
|
2781
|
+
// Dedup gate: the same objective + owner already active anywhere in the
|
|
2782
|
+
// workspace family (this store or any worktree's) is reused, never cloned.
|
|
2783
|
+
// Born 2026-07-02: an hourly alive loop spawned six identical auto-improver
|
|
2784
|
+
// missions in six fresh worktrees in one day — pure token burn. --duplicate
|
|
2785
|
+
// is the explicit escape hatch.
|
|
2786
|
+
const TWIN_ACTIVE_STATUSES = new Set(['planning', 'ready', 'running']);
|
|
2787
|
+
|
|
2788
|
+
function normalizedObjective(text) {
|
|
2789
|
+
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
function findActiveTwinMission(objective, owner, root = process.cwd()) {
|
|
2793
|
+
const wantObjective = normalizedObjective(objective);
|
|
2794
|
+
const wantOwner = String(owner || '').trim().toLowerCase();
|
|
2795
|
+
if (!wantObjective) return null;
|
|
2796
|
+
const candidates = [
|
|
2797
|
+
...listMissions(root),
|
|
2798
|
+
...listWorktreeRollupMissions(root),
|
|
2799
|
+
];
|
|
2800
|
+
for (const m of candidates) {
|
|
2801
|
+
if (!m || !TWIN_ACTIVE_STATUSES.has(m.status)) continue;
|
|
2802
|
+
if (normalizedObjective(m.objective) !== wantObjective) continue;
|
|
2803
|
+
if (String(m.owner || '').trim().toLowerCase() !== wantOwner) continue;
|
|
2804
|
+
return m;
|
|
2805
|
+
}
|
|
2806
|
+
return null;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2781
2809
|
function startMission(args) {
|
|
2782
2810
|
const asJson = wantsJson(args);
|
|
2783
2811
|
const mission = missionFromArgs(args);
|
|
2812
|
+
if (!hasFlag(args, '--duplicate')) {
|
|
2813
|
+
const twin = findActiveTwinMission(mission.objective, mission.owner);
|
|
2814
|
+
if (twin) {
|
|
2815
|
+
printJsonOrText(
|
|
2816
|
+
{ ok: true, action: 'mission_reused', reused: true, mission: twin, note: 'an active mission with this objective and owner already exists; resumed instead of cloning (pass --duplicate to force a second one)' },
|
|
2817
|
+
[
|
|
2818
|
+
`Already active: ${twin.id} (${twin.status})`,
|
|
2819
|
+
`Same objective, same owner — reusing it instead of starting a clone.`,
|
|
2820
|
+
`Resume: atris mission run ${twin.id}`,
|
|
2821
|
+
`Really want a second one: re-run with --duplicate`,
|
|
2822
|
+
],
|
|
2823
|
+
asJson,
|
|
2824
|
+
);
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2784
2828
|
// --worktree: bind the mission to its own isolated checkout. We chdir before
|
|
2785
2829
|
// any state writes so the mission record, baseline sidecar, receipts, and
|
|
2786
2830
|
// member files all land inside the worktree — ticks run there, and the main
|
package/lib/fleet.js
CHANGED
|
@@ -269,6 +269,9 @@ async function runFleetFlight({
|
|
|
269
269
|
})();
|
|
270
270
|
|
|
271
271
|
const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
|
|
272
|
+
// The receipt path is decided BEFORE landings so each task's ready-proof
|
|
273
|
+
// can cite it — the proof policy certifies receipt-backed proofs agent-side.
|
|
274
|
+
const receiptPath = path.join(root, 'atris', 'runs', `fleet-${nowStamp()}.json`);
|
|
272
275
|
const flight = {
|
|
273
276
|
at: new Date().toISOString(),
|
|
274
277
|
root,
|
|
@@ -335,7 +338,7 @@ async function runFleetFlight({
|
|
|
335
338
|
const landed = land({ entry, result });
|
|
336
339
|
if (landed.ok) {
|
|
337
340
|
flight.landed.push({ task: entry.task.display_id, engine: entry.engine });
|
|
338
|
-
cli(['task', 'ready', String(entry.task.display_id), '--proof', `Built by ${entry.engine} engine in fleet flight, landed via worktree ship gate (rebase-before-ship, verify re-run). Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`, '--as', `fleet-${entry.engine}`]);
|
|
341
|
+
cli(['task', 'ready', String(entry.task.display_id), '--proof', `Built by ${entry.engine} engine in fleet flight, landed via worktree ship gate (rebase-before-ship, verify re-run). Receipt saved at ${path.relative(root, receiptPath)}. Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`, '--as', `fleet-${entry.engine}`]);
|
|
339
342
|
log(` ${entry.engine.padEnd(8)} ✓ landed ${entry.task.display_id}`);
|
|
340
343
|
} else {
|
|
341
344
|
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, ...landed });
|
|
@@ -343,9 +346,8 @@ async function runFleetFlight({
|
|
|
343
346
|
}
|
|
344
347
|
}
|
|
345
348
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
flight.receipt = path.join(receiptDir, `fleet-${nowStamp()}.json`);
|
|
349
|
+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
|
|
350
|
+
flight.receipt = receiptPath;
|
|
349
351
|
fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
|
|
350
352
|
log('');
|
|
351
353
|
log(` flight over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
|