atris 3.71.0 → 3.71.2
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 +12 -2
- package/commands/campaign.js +56 -11
- package/commands/integrations.js +17 -5
- package/commands/mission.js +23 -7
- package/lib/account-bound.js +13 -0
- package/lib/campaign-cadence.js +13 -6
- package/lib/campaign-core.js +12 -1
- package/lib/campaign-lanes.js +52 -0
- package/lib/campaign-loop.js +48 -9
- package/lib/campaign-protect.js +7 -1
- package/lib/campaign-run-lock.js +59 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: campaigns
|
|
3
|
-
description: "Run an Atris campaign: a long goal with a judge that fails today
|
|
3
|
+
description: "Run an Atris campaign: a long goal (hours to days) with a judge that fails today and members in seats, worked by missions until the judge passes. Use only when the path is unknown and takes many tries; a quick fix or a single mission is not a campaign. Triggers on: run a campaign, long goal, big goal, keep going until it passes, campaign, judge, budget, atris campaign."
|
|
4
4
|
version: 1.0.0
|
|
5
5
|
tags:
|
|
6
6
|
- campaign
|
|
@@ -12,14 +12,18 @@ tags:
|
|
|
12
12
|
|
|
13
13
|
# Campaigns
|
|
14
14
|
|
|
15
|
-
A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions
|
|
15
|
+
A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions until the judge passes, a budget (cloud only) runs out, or a person has to decide something.
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Size it first
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
- **Campaign**: the goal needs many missions, more than one member, or a spending cap. A campaign decides what to do next; its missions do the work.
|
|
19
|
+
Ask one question: do you already know the steps?
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
- **Quick fix** (minutes): you know the file and the fix. Edit it, run the tests that cover it, ship. No mission, no judge.
|
|
22
|
+
- **Mission** (an hour or a few): you know what done looks like and roughly how. One `atris mission start`, or a few across members.
|
|
23
|
+
- **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. A campaign decides what to do next; its missions do the work.
|
|
24
|
+
- **Purpose** (months to years): a standing campaign (`--standing`). Campaigns run under it (`--parent`), and each one's lessons feed the next.
|
|
25
|
+
|
|
26
|
+
A campaign is a vision, not a fix. If one mission would finish it, use a mission; if you could write the diff in ten minutes, write it. Measured 2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes.
|
|
23
27
|
|
|
24
28
|
## The five commands
|
|
25
29
|
|
package/atris.md
CHANGED
|
@@ -131,6 +131,7 @@ Six primitives, one sentence each:
|
|
|
131
131
|
- **The Mission** (MISSION.md): the enduring purpose. Never ends. Sits at the top and helps members pick goals.
|
|
132
132
|
- **Goals** (endgame, member goals): direction derived from the Mission. A goal points; it does not commit.
|
|
133
133
|
- **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.
|
|
134
|
+
- **A campaign** (`atris campaign`): a measurable goal with a judge that fails today, worked by many mission runs until it passes. Hours to days.
|
|
134
135
|
- **Tasks** (`atris task`): the smallest provable units. Receipts, then human accept.
|
|
135
136
|
- **Members** (`atris member`): the WHO. A role with memory and judgment doing one bounded, proven slice at a time.
|
|
136
137
|
- **Loops** (`atris loops`): the WHEN. Clocks that never finish, only fire. Health is exit 0 on cadence.
|
|
@@ -236,6 +237,14 @@ Before picking up work, decide scope:
|
|
|
236
237
|
- single project → route to that project's `atris/team/` and `atris task` queue
|
|
237
238
|
- crosses projects → route to `atris/team/cross-project-architect/` and plan the dependency order first
|
|
238
239
|
|
|
240
|
+
Then size it, before any tool runs. Ask one question: do I already know the steps?
|
|
241
|
+
- **Quick fix** (minutes): you know the file and the fix. Do it, run the tests that cover it, ship. No mission, no judge.
|
|
242
|
+
- **Mission** (an hour or a few): you know what done looks like and roughly how. One mission run, or a few across members (builder builds, validator checks).
|
|
243
|
+
- **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. `atris campaign` with a judge that fails today; its missions do the work.
|
|
244
|
+
- **Purpose** (months to years): a standing campaign under the Mission. Its judge runs on a schedule, campaigns run under it, and each one's lessons and scored record feed the next. This is the self-improving loop.
|
|
245
|
+
|
|
246
|
+
Never size up for show. A fix run as a campaign wastes time and inference and leaves bad data (2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes). Sizing is judgment, not a command: `atris wish` is for imagining, not for sizing.
|
|
247
|
+
|
|
239
248
|
The human is the constructor. You multiply. Handoff fidelity lives in the files, not in context.
|
|
240
249
|
|
|
241
250
|
## next
|
package/bin/atris.js
CHANGED
|
@@ -3003,7 +3003,12 @@ if (command === 'guide') {
|
|
|
3003
3003
|
const mailbox = extractGmailMailboxAccount(raw);
|
|
3004
3004
|
// Mailbox --account <id> is a selector, not the workspace opt-in.
|
|
3005
3005
|
// Scratch folders refuse every gmail verb the same way as slack/inbox.
|
|
3006
|
-
|
|
3006
|
+
// Read verbs also run from a project folder (atris/, no business.json).
|
|
3007
|
+
const GMAIL_READ_VERBS = new Set(['inbox', 'list', 'read', 'triage', 'verdicts', 'accounts']);
|
|
3008
|
+
const gate = requireAccountBound(
|
|
3009
|
+
mailbox.args.filter((arg) => arg !== '--account'),
|
|
3010
|
+
{ read: GMAIL_READ_VERBS.has(mailbox.args[0]) },
|
|
3011
|
+
);
|
|
3007
3012
|
if (!gate.ok) {
|
|
3008
3013
|
process.exit(refuseAccountGlobal());
|
|
3009
3014
|
return;
|
|
@@ -3042,7 +3047,12 @@ if (command === 'guide') {
|
|
|
3042
3047
|
const account = extractGmailMailboxAccount(raw);
|
|
3043
3048
|
// Account --account <id> is a selector, not the workspace opt-in.
|
|
3044
3049
|
// Scratch folders refuse every slack verb the same way as gmail/inbox.
|
|
3045
|
-
|
|
3050
|
+
// Read verbs also run from a project folder (atris/, no business.json).
|
|
3051
|
+
const SLACK_READ_VERBS = new Set(['channels', 'list', 'dms', 'messages', 'read', 'search']);
|
|
3052
|
+
const gate = requireAccountBound(
|
|
3053
|
+
account.args.filter((arg) => arg !== '--account'),
|
|
3054
|
+
{ read: SLACK_READ_VERBS.has(account.args[0]) },
|
|
3055
|
+
);
|
|
3046
3056
|
if (!gate.ok) {
|
|
3047
3057
|
process.exit(refuseAccountGlobal());
|
|
3048
3058
|
return;
|
package/commands/campaign.js
CHANGED
|
@@ -11,12 +11,12 @@ const path = require('path');
|
|
|
11
11
|
const { spawn } = require('child_process');
|
|
12
12
|
const { readFlag } = require('../lib/arg-parser');
|
|
13
13
|
const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign-schema');
|
|
14
|
-
const { decide } = require('../lib/campaign-core');
|
|
14
|
+
const { decide, paysDollars } = require('../lib/campaign-core');
|
|
15
15
|
const { campaignDbPath, getCampaign, listCampaigns, newCampaignId, openCampaignStore } = require('../lib/campaign-store');
|
|
16
16
|
const { findHoldout, moveHoldoutBack, moveHoldoutIn, scrub } = require('../lib/campaign-holdout');
|
|
17
17
|
const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
|
|
18
18
|
const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
|
|
19
|
-
const { campaignHome, changedProtected, checkoutTop, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
|
|
19
|
+
const { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
|
|
20
20
|
|
|
21
21
|
const JUDGE_TIMEOUT_SECONDS = 15 * 60;
|
|
22
22
|
// Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
|
|
@@ -46,7 +46,7 @@ function printHelp() {
|
|
|
46
46
|
console.log(' atris campaign answer <id> <ask-id> "<answer>" a person answers; agents cannot');
|
|
47
47
|
console.log(' atris campaign pause|resume|stop <id>');
|
|
48
48
|
console.log(' atris campaign event <id> <type> [--data \'<json>\'] append any event by hand');
|
|
49
|
-
console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--runner auto]');
|
|
49
|
+
console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--mission-minutes 40] [--runner auto]');
|
|
50
50
|
console.log(' the campaign runs itself: proposes missions, runs them at once, judges, repeats until done, broke, or needing you');
|
|
51
51
|
console.log(' add --work 50m --break 10m [--rounds N] to work in rounds; each break judges and writes lessons the next round plans from');
|
|
52
52
|
console.log(' atris campaign step <id> one step of that loop');
|
|
@@ -79,7 +79,7 @@ const NO_LIMIT_BUDGET = 1e9;
|
|
|
79
79
|
const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
|
|
80
80
|
|
|
81
81
|
// The flags this command understands, so positional words are what is left.
|
|
82
|
-
const VALUE_FLAGS = ['--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
|
|
82
|
+
const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
|
|
83
83
|
|
|
84
84
|
function positionals(args) {
|
|
85
85
|
const out = [];
|
|
@@ -211,6 +211,13 @@ function keepJudgeScript(judge, folder) {
|
|
|
211
211
|
return { from: file, judge: shellQuote(copy) };
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
// The commit saved on a campaign's start, or null when it started outside git.
|
|
215
|
+
function savedStartCommit(store, campaignId) {
|
|
216
|
+
if (!campaignId) return null;
|
|
217
|
+
const start = store.read(campaignId).find((event) => event.type === 'campaign_started');
|
|
218
|
+
return (start && start.data && start.data.start_commit) || null;
|
|
219
|
+
}
|
|
220
|
+
|
|
214
221
|
function makeContext(args, deps) {
|
|
215
222
|
const asJson = args.includes('--json');
|
|
216
223
|
const store = deps.store || openCampaignStore(process.cwd());
|
|
@@ -220,10 +227,15 @@ function makeContext(args, deps) {
|
|
|
220
227
|
// Held-out files live beside the campaign store, outside every checkout.
|
|
221
228
|
const holdoutRoot = deps.holdoutRoot || path.dirname(store.path || campaignDbPath());
|
|
222
229
|
// A campaign with a held-out file hands its path to the judge, and nothing
|
|
223
|
-
// the judge prints from it reaches the store.
|
|
224
|
-
|
|
230
|
+
// the judge prints from it reaches the store. A campaign started in a git
|
|
231
|
+
// checkout hands the judge the commit it started from. Start passes its
|
|
232
|
+
// commit in, since nothing is saved yet.
|
|
233
|
+
const judge = async (command, timeout, campaignId, startCommit = savedStartCommit(store, campaignId)) => {
|
|
225
234
|
const holdout = campaignId ? findHoldout(holdoutRoot, campaignId) : null;
|
|
226
|
-
const
|
|
235
|
+
const env = { ...process.env };
|
|
236
|
+
if (holdout) env.ATRIS_CAMPAIGN_HOLDOUT = holdout;
|
|
237
|
+
if (startCommit) env.ATRIS_CAMPAIGN_START_COMMIT = startCommit;
|
|
238
|
+
const reading = await runJudge(command, timeout, env);
|
|
227
239
|
if (!holdout) return reading;
|
|
228
240
|
return { ...reading, summary: scrub(reading.summary, holdout), output_tail: scrub(reading.output_tail, holdout) };
|
|
229
241
|
};
|
|
@@ -250,6 +262,14 @@ function requireCampaign(ctx, id) {
|
|
|
250
262
|
return { view };
|
|
251
263
|
}
|
|
252
264
|
|
|
265
|
+
// On a subscription the budget is still a cap, measured at API prices, so the
|
|
266
|
+
// person sees how much of it is used without a dollar figure they never paid.
|
|
267
|
+
function spentText(view, paid) {
|
|
268
|
+
if (paid) return `${money(view.budget.spent)} spent of ${money(view.budget.total)}`;
|
|
269
|
+
const used = view.budget.total > 0 ? Math.round((view.budget.spent / view.budget.total) * 100) : 0;
|
|
270
|
+
return `on your subscription, nothing spent · ${used}% of the budget used`;
|
|
271
|
+
}
|
|
272
|
+
|
|
253
273
|
function renderList(views) {
|
|
254
274
|
if (!views.length) {
|
|
255
275
|
return 'No campaigns yet. Start one:\n atris campaign "<goal as a sentence>" --judge "<command that fails today>"';
|
|
@@ -259,7 +279,7 @@ function renderList(views) {
|
|
|
259
279
|
for (const view of sorted) {
|
|
260
280
|
const running = view.missions.filter((m) => m.status === 'running').length;
|
|
261
281
|
lines.push(`${clip(view.title)} (${view.id})`);
|
|
262
|
-
const spend = noLimit(view) ? '' : ` · ${
|
|
282
|
+
const spend = noLimit(view) ? '' : ` · ${spentText(view, paysDollars(view))}`;
|
|
263
283
|
lines.push(` ${view.state.replace(/_/g, ' ')}${spend} · ${running} mission${running === 1 ? '' : 's'} running · answer ${view.answer.confidence}`);
|
|
264
284
|
lines.push(` next: ${nextMove(view).text}`);
|
|
265
285
|
}
|
|
@@ -274,14 +294,17 @@ function renderShow(view) {
|
|
|
274
294
|
const last = view.last_judge;
|
|
275
295
|
row('judge', `${view.judge}${last ? ` · last run ${last.passed ? 'passed' : 'failed'} at ${last.at}` : ' · never run'}${view.judge_due && last ? ' · due again' : ''}`);
|
|
276
296
|
row('answer', `${view.answer.confidence}${view.answer.text ? `: ${view.answer.text}` : ''}`);
|
|
277
|
-
if (!noLimit(view))
|
|
297
|
+
if (!noLimit(view)) {
|
|
298
|
+
const paid = paysDollars(view);
|
|
299
|
+
row('budget', paid ? `${spentText(view, paid)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left` : spentText(view, paid));
|
|
300
|
+
}
|
|
278
301
|
row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
|
|
279
302
|
if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
|
|
280
303
|
if (view.parent) row('parent', view.parent);
|
|
281
304
|
row('seats', view.seats.length ? view.seats.map((s) => (s.role ? `${s.member} (${s.role})` : s.member)).join(', ') : 'none');
|
|
282
305
|
row('missions', view.missions.length ? '' : 'none');
|
|
283
306
|
for (const m of view.missions) {
|
|
284
|
-
const cost = m.cost_usd === null ? '' : ` · ${money(m.cost_usd)}`;
|
|
307
|
+
const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
|
|
285
308
|
lines.push(` ${m.id.padEnd(22)} ${m.status.padEnd(8)} ${m.member || '-'}${m.outcome ? ` · ${m.outcome}` : ''}${cost}`);
|
|
286
309
|
}
|
|
287
310
|
row('asks', view.asks.length ? '' : 'none');
|
|
@@ -352,7 +375,8 @@ async function cmdStart(args, ctx) {
|
|
|
352
375
|
|
|
353
376
|
const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
|
|
354
377
|
if (!ctx.asJson) console.error(`running the judge first: ${judge}`);
|
|
355
|
-
const
|
|
378
|
+
const startCommit = headCommit(process.cwd());
|
|
379
|
+
const reading = await ctx.judge(judge, timeout, id, startCommit);
|
|
356
380
|
if (reading.passed && !standing) {
|
|
357
381
|
moveHoldoutBack(held);
|
|
358
382
|
return ctx.fail(`already done: the judge passes before any work (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
|
|
@@ -371,6 +395,7 @@ async function cmdStart(args, ctx) {
|
|
|
371
395
|
if (bet && !wrongIf) startData.bet = bet;
|
|
372
396
|
if (standing) startData.standing = true;
|
|
373
397
|
if (basis) startData.basis = basis;
|
|
398
|
+
if (startCommit) startData.start_commit = startCommit;
|
|
374
399
|
ctx.append(id, 'campaign_started', startData);
|
|
375
400
|
if (bet && wrongIf) ctx.append(id, 'bet_set', { bet, wrong_if: wrongIf });
|
|
376
401
|
if (parent) ctx.append(id, 'attached', { parent_id: parent });
|
|
@@ -639,6 +664,25 @@ const cmdEvent = (args, ctx) => {
|
|
|
639
664
|
async function cmdLoop(args, ctx, verb) {
|
|
640
665
|
const { view, error } = requireCampaign(ctx, positionals(args)[1]);
|
|
641
666
|
if (error !== undefined) return error;
|
|
667
|
+
// One driver per campaign: a second loop would plan and start missions
|
|
668
|
+
// against the same budget and files (lib/campaign-run-lock.js).
|
|
669
|
+
let lock = null;
|
|
670
|
+
if (ctx.store.path && ctx.campaignHome) {
|
|
671
|
+
lock = require('../lib/campaign-run-lock').acquireRunLock(ctx.campaignHome, view.id);
|
|
672
|
+
if (!lock.ok) {
|
|
673
|
+
const holder = lock.holder || {};
|
|
674
|
+
return ctx.fail(`another run is driving ${view.id} (pid ${holder.pid || '?'} in ${holder.cwd || '?'} since ${holder.started_at || '?'}); wait for it to stop or stop that process`, 1);
|
|
675
|
+
}
|
|
676
|
+
process.once('exit', lock.release);
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
return await driveLoop(args, ctx, verb, view);
|
|
680
|
+
} finally {
|
|
681
|
+
if (lock) lock.release();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function driveLoop(args, ctx, verb, view) {
|
|
642
686
|
const { makeLoop } = require('../lib/campaign-loop');
|
|
643
687
|
// A synced campaign trades events around every step. Failures warn and the
|
|
644
688
|
// loop runs on offline; a refusal (403, 409) stops syncing for this run
|
|
@@ -666,6 +710,7 @@ async function cmdLoop(args, ctx, verb) {
|
|
|
666
710
|
const loop = makeLoop(process.cwd(), {
|
|
667
711
|
parallel: readFlag(args, '--parallel', '') || readFlag(args, '--count', ''),
|
|
668
712
|
maxTicks: readFlag(args, '--max-ticks', ''),
|
|
713
|
+
missionMinutes: readFlag(args, '--mission-minutes', ''),
|
|
669
714
|
runner: readFlag(args, '--runner', ''),
|
|
670
715
|
actor: ctx.actor,
|
|
671
716
|
}, {
|
package/commands/integrations.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* atris gmail archive <id> [...] [--account <id>] - Archive messages
|
|
8
8
|
* atris gmail triage [--account <id>] [--limit N] - Record keep or archive verdicts
|
|
9
9
|
* atris gmail verdicts [--summary] [--account <id>] [--limit N] - List recent Gmail verdicts
|
|
10
|
-
* atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - Send an email
|
|
10
|
+
* atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - Send an email
|
|
11
11
|
* atris gmail voice [account] [--clear] - Edit an account's writing voice
|
|
12
12
|
* atris gmail connect [name] - Connect or reconnect a Gmail account
|
|
13
13
|
* atris gmail accounts - List connected Gmail accounts and the active account
|
|
@@ -41,7 +41,7 @@ const { sleepSync } = require('../lib/sleep-sync');
|
|
|
41
41
|
const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
|
|
42
42
|
const GMAIL_CONNECT_POLL_MS = 3000;
|
|
43
43
|
const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
|
|
44
|
-
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
|
|
44
|
+
const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>]';
|
|
45
45
|
const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
|
|
46
46
|
const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
|
|
47
47
|
const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
|
|
@@ -560,10 +560,15 @@ async function gmailArchive(messageIds, options = {}) {
|
|
|
560
560
|
function parseGmailSendArgs(args = []) {
|
|
561
561
|
let accountId = null;
|
|
562
562
|
let bodyFile = null;
|
|
563
|
+
let approved = false;
|
|
563
564
|
const positional = [];
|
|
564
565
|
|
|
565
566
|
for (let i = 0; i < args.length; i += 1) {
|
|
566
567
|
const arg = args[i];
|
|
568
|
+
if (arg === '--approved' || arg === '--confirm-approved') {
|
|
569
|
+
approved = true;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
567
572
|
if (arg === '--account' || arg === '--body-file') {
|
|
568
573
|
const value = String(args[i + 1] || '').trim();
|
|
569
574
|
if (!value || value.startsWith('--')) {
|
|
@@ -596,10 +601,17 @@ function parseGmailSendArgs(args = []) {
|
|
|
596
601
|
}
|
|
597
602
|
}
|
|
598
603
|
|
|
599
|
-
return { to, subject, body, accountId };
|
|
604
|
+
return { to, subject, body, accountId, approved };
|
|
600
605
|
}
|
|
601
606
|
|
|
602
607
|
async function gmailSend(to, subject, body, options = {}) {
|
|
608
|
+
// Approval is checked before any token lookup or network call so an
|
|
609
|
+
// unapproved send can never reach the server.
|
|
610
|
+
if (!options.approved) {
|
|
611
|
+
console.error('Refusing to send without --approved after the exact recipient, subject, and body are confirmed.');
|
|
612
|
+
console.error(GMAIL_SEND_USAGE);
|
|
613
|
+
process.exit(2);
|
|
614
|
+
}
|
|
603
615
|
const token = await getAuthToken();
|
|
604
616
|
const accountId = resolveGmailAccountId(options.accountId);
|
|
605
617
|
const accounts = await fetchGmailAccounts(token);
|
|
@@ -864,7 +876,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
864
876
|
}
|
|
865
877
|
case 'send': {
|
|
866
878
|
const parsed = parseGmailSendArgs(args);
|
|
867
|
-
await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined });
|
|
879
|
+
await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined, approved: parsed.approved });
|
|
868
880
|
break;
|
|
869
881
|
}
|
|
870
882
|
case 'voice': {
|
|
@@ -888,7 +900,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
888
900
|
console.log(' atris gmail archive <id> [...] [--account <id>] - archive messages (reversible, all mail keeps them)');
|
|
889
901
|
console.log(' atris gmail triage [--account <id>] [--limit N] - record keep or archive verdicts without changing mail');
|
|
890
902
|
console.log(' atris gmail verdicts [--summary] [--account <id>] [--limit N] - list recent gmail verdicts');
|
|
891
|
-
console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
|
|
903
|
+
console.log(' atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - send an email');
|
|
892
904
|
console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
|
|
893
905
|
console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
|
|
894
906
|
console.log(' atris gmail accounts - list connected gmail accounts and the active account');
|
package/commands/mission.js
CHANGED
|
@@ -6989,18 +6989,34 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
|
|
|
6989
6989
|
// before the tick fires. Same tag-plus-text routing the task lane uses
|
|
6990
6990
|
// (CLI-1189). An operator override is explicit: protected_lane_ack in the
|
|
6991
6991
|
// mission metadata, set by a human, or ATRIS_ALLOW_PROTECTED_MISSION=1.
|
|
6992
|
-
function missionProtectedLaneHold(mission) {
|
|
6992
|
+
function missionProtectedLaneHold(mission, root = process.cwd()) {
|
|
6993
6993
|
if (process.env.ATRIS_ALLOW_PROTECTED_MISSION === '1') return null;
|
|
6994
6994
|
if (mission?.metadata?.protected_lane_ack) return null;
|
|
6995
6995
|
const { DENIED_TAGS } = require('../lib/fleet');
|
|
6996
6996
|
const { declaredProtectedLane, sniffedProtectedLane } = require('../lib/auto-accept-certified');
|
|
6997
6997
|
const lane = String(mission?.lane || '').toLowerCase();
|
|
6998
|
-
if (DENIED_TAGS.includes(lane)) return { pause_reason: `protected-lane-${lane}` };
|
|
6999
6998
|
const probe = { title: mission?.objective, objective: mission?.stop_condition, metadata: mission?.metadata || {} };
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
if (
|
|
7003
|
-
|
|
6999
|
+
let hold = null;
|
|
7000
|
+
if (DENIED_TAGS.includes(lane)) hold = { pause_reason: `protected-lane-${lane}`, lane };
|
|
7001
|
+
else if (declaredProtectedLane(probe)) hold = { pause_reason: 'protected-lane-declared', lane: 'declared' };
|
|
7002
|
+
else {
|
|
7003
|
+
const sniffed = sniffedProtectedLane(probe);
|
|
7004
|
+
if (sniffed) hold = { pause_reason: `protected-lane-${sniffed.lane}`, lane: sniffed.lane };
|
|
7005
|
+
}
|
|
7006
|
+
// Inside a campaign, a person may have allowed this lane by answering the
|
|
7007
|
+
// campaign's ask. Only a person's answer closes an ask, so it counts as the ack.
|
|
7008
|
+
if (hold && mission?.campaign_id && campaignApprovedLane(root, mission.campaign_id, hold.lane)) return null;
|
|
7009
|
+
return hold ? { pause_reason: hold.pause_reason } : null;
|
|
7010
|
+
}
|
|
7011
|
+
|
|
7012
|
+
function campaignApprovedLane(root, campaignId, lane) {
|
|
7013
|
+
try {
|
|
7014
|
+
const { openCampaignStore } = require('../lib/campaign-store');
|
|
7015
|
+
const { approvedLanes } = require('../lib/campaign-lanes');
|
|
7016
|
+
return approvedLanes(openCampaignStore(root).read(campaignId)).has(lane);
|
|
7017
|
+
} catch {
|
|
7018
|
+
return false;
|
|
7019
|
+
}
|
|
7004
7020
|
}
|
|
7005
7021
|
|
|
7006
7022
|
function runnerUsesCallerSession(runner) {
|
|
@@ -10342,7 +10358,7 @@ async function executeMissionRunTicksPhase(context) {
|
|
|
10342
10358
|
if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
|
|
10343
10359
|
if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
|
|
10344
10360
|
if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
|
|
10345
|
-
const protectedHold = missionProtectedLaneHold(mission);
|
|
10361
|
+
const protectedHold = missionProtectedLaneHold(mission, cwd);
|
|
10346
10362
|
if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
|
|
10347
10363
|
|
|
10348
10364
|
const tickIdx = Number(mission.last_tick_index || 0) + 1;
|
package/lib/account-bound.js
CHANGED
|
@@ -20,6 +20,14 @@ function isProvisionedAgentWorkspace(cwd = process.cwd()) {
|
|
|
20
20
|
return fs.existsSync(path.join(root, '.atris', 'agent-token.json'));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A Desktop project folder carries an atris/ directory but no business.json.
|
|
24
|
+
// It is a real workspace, not a scratch folder, so account reads may run
|
|
25
|
+
// there. Writes (send, dm, archive, connect) still need the business binding.
|
|
26
|
+
function isProjectWorkspace(cwd = process.cwd()) {
|
|
27
|
+
const root = path.resolve(cwd);
|
|
28
|
+
return fs.existsSync(path.join(root, 'atris'));
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
function isHelpArg(arg) {
|
|
24
32
|
return arg === '--help' || arg === '-h' || arg === 'help';
|
|
25
33
|
}
|
|
@@ -33,6 +41,8 @@ function looksLikeBusinessSlug(token) {
|
|
|
33
41
|
/**
|
|
34
42
|
* Account-scoped verbs dump live CRM/cloud state. Outside a bound business
|
|
35
43
|
* workspace they must opt in with --account. Help always passes.
|
|
44
|
+
* Pass { read: true } for read-only verbs: a project folder with atris/ (no
|
|
45
|
+
* business.json) then counts as bound. Writes never get that relaxation.
|
|
36
46
|
* Returns { ok, args } where args has --account stripped when continuing.
|
|
37
47
|
*/
|
|
38
48
|
function requireAccountBound(args = [], options = {}) {
|
|
@@ -44,6 +54,9 @@ function requireAccountBound(args = [], options = {}) {
|
|
|
44
54
|
if (isBoundBusinessWorkspace(cwd) || isProvisionedAgentWorkspace(cwd)) {
|
|
45
55
|
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true };
|
|
46
56
|
}
|
|
57
|
+
if (options.read === true && isProjectWorkspace(cwd)) {
|
|
58
|
+
return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true, project: true };
|
|
59
|
+
}
|
|
47
60
|
if (list.includes('--account')) {
|
|
48
61
|
return { ok: true, args: list.filter((arg) => arg !== '--account'), account: true };
|
|
49
62
|
}
|
package/lib/campaign-cadence.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
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');
|
|
13
14
|
|
|
14
15
|
const LESSON_LINES_FOR_PLANNING = 30;
|
|
@@ -44,8 +45,14 @@ function roundSummary(before, after) {
|
|
|
44
45
|
return { kept, failed, vetoed, spent, fresh };
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// On a subscription the round is told without dollars, since none were paid.
|
|
49
|
+
function roundTally(view, summary) {
|
|
50
|
+
return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
function reviewPrompt(view, summary, lessons) {
|
|
48
|
-
const
|
|
54
|
+
const paid = paysDollars(view);
|
|
55
|
+
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
56
|
return [
|
|
50
57
|
'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
|
|
51
58
|
'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
|
|
@@ -53,7 +60,7 @@ function reviewPrompt(view, summary, lessons) {
|
|
|
53
60
|
`Campaign: ${view.title}`,
|
|
54
61
|
`Judge: ${view.judge}`,
|
|
55
62
|
`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
|
|
63
|
+
`This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start${paid ? `, $${summary.spent} spent` : ''}`,
|
|
57
64
|
'Missions this round:',
|
|
58
65
|
missions,
|
|
59
66
|
'',
|
|
@@ -88,9 +95,9 @@ function makeCadence(root, loop, options = {}, deps = {}) {
|
|
|
88
95
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
96
|
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
97
|
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
|
|
91
|
-
const header = `\n## ${stamp}: ${
|
|
98
|
+
const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
|
|
92
99
|
fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
|
|
93
|
-
return { summary, lessons: written };
|
|
100
|
+
return { summary, lessons: written, view: after };
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
async function run(id) {
|
|
@@ -111,8 +118,8 @@ function makeCadence(root, loop, options = {}, deps = {}) {
|
|
|
111
118
|
}
|
|
112
119
|
const breakStarted = now();
|
|
113
120
|
log(`round ${round}: review`);
|
|
114
|
-
const { summary, lessons } = await review(id, before);
|
|
115
|
-
log(` ${
|
|
121
|
+
const { summary, lessons, view: reviewed } = await review(id, before);
|
|
122
|
+
log(` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
|
|
116
123
|
const view = getCampaign(store, id);
|
|
117
124
|
last = { reason: stopped || 'work window ended', round };
|
|
118
125
|
// Finished, out of money, or waiting on a person: no point in another round.
|
package/lib/campaign-core.js
CHANGED
|
@@ -282,6 +282,9 @@ function fold(events) {
|
|
|
282
282
|
bet: s.bet,
|
|
283
283
|
parent: s.parent,
|
|
284
284
|
home: s.home,
|
|
285
|
+
// Only when the start event names one, so the shared golden views stay as
|
|
286
|
+
// pinned; no basis means the person's own engines.
|
|
287
|
+
...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
|
|
285
288
|
created_from: d.created_from === undefined ? null : d.created_from,
|
|
286
289
|
seats: s.seats,
|
|
287
290
|
state,
|
|
@@ -327,4 +330,12 @@ function decide(view) {
|
|
|
327
330
|
return { action: 'propose', reason: 'no missions queued' };
|
|
328
331
|
}
|
|
329
332
|
|
|
330
|
-
|
|
333
|
+
// Spend is real dollars only when billing is API or Atris runs the campaign on
|
|
334
|
+
// its own engines (basis atris_cloud). Otherwise the work ran on the person's own
|
|
335
|
+
// subscriptions: they paid nothing, and cost_usd stays as the API-price budget meter.
|
|
336
|
+
function paysDollars(view) {
|
|
337
|
+
if (process.env.ATRIS_BILLING === 'api') return true;
|
|
338
|
+
return Boolean(view && view.basis === 'atris_cloud');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = { cents, compareEvents, decide, fold, paysDollars };
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
// True when this campaign already has an open ask for the lane, so the loop asks once.
|
|
36
|
+
function laneAskOpen(events, lane) {
|
|
37
|
+
const open = new Set();
|
|
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 === lane) open.add(data.ask_id);
|
|
41
|
+
if (event.type === 'ask_answered') open.delete(data.ask_id);
|
|
42
|
+
}
|
|
43
|
+
return open.size > 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function laneQuestion(lane, member, objective) {
|
|
47
|
+
const what = String(objective || '').replace(/\s+/g, ' ').trim();
|
|
48
|
+
const short = what.length > 140 ? `${what.slice(0, 139)}…` : what;
|
|
49
|
+
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.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion };
|
package/lib/campaign-loop.js
CHANGED
|
@@ -12,6 +12,7 @@ const { decide } = require('./campaign-core');
|
|
|
12
12
|
const { getCampaign, openCampaignStore } = require('./campaign-store');
|
|
13
13
|
const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
|
|
14
14
|
const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
|
|
15
|
+
const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion } = require('./campaign-lanes');
|
|
15
16
|
|
|
16
17
|
const TERMINAL = new Set(['stopped', 'complete']);
|
|
17
18
|
|
|
@@ -86,17 +87,37 @@ async function askOwner(root, view, prompt) {
|
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
// Run one atris command and resolve with its exit code and output.
|
|
90
|
+
// A live step an engine prints while a mission runs: "<member> <hh:mm> <what it is doing>".
|
|
91
|
+
const ACTIVITY_LINE = /^\s*[a-z0-9][a-z0-9-]*\s+\d{1,2}:\d{2}\s{2,}\S/i;
|
|
92
|
+
|
|
89
93
|
function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
|
|
90
94
|
return new Promise((resolve) => {
|
|
91
95
|
const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
92
96
|
let out = '';
|
|
93
97
|
let err = '';
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
// Whole lines only: a chunk can end mid-line. Live engine steps arrive on
|
|
99
|
+
// stderr (member-progress reportProgress), so both streams are read.
|
|
100
|
+
const reader = () => {
|
|
101
|
+
let partial = '';
|
|
102
|
+
return {
|
|
103
|
+
push(chunk) {
|
|
104
|
+
if (!onLine) return;
|
|
105
|
+
const lines = (partial + String(chunk)).split('\n');
|
|
106
|
+
partial = lines.pop();
|
|
107
|
+
lines.filter(Boolean).forEach(onLine);
|
|
108
|
+
},
|
|
109
|
+
end() { if (onLine && partial) onLine(partial); },
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const stdout = reader();
|
|
113
|
+
const stderr = reader();
|
|
114
|
+
child.stdout.on('data', (chunk) => { out += chunk; stdout.push(chunk); });
|
|
115
|
+
child.stderr.on('data', (chunk) => { err += chunk; stderr.push(chunk); });
|
|
116
|
+
child.on('close', (code) => {
|
|
117
|
+
stdout.end();
|
|
118
|
+
stderr.end();
|
|
119
|
+
resolve({ code, out, err });
|
|
97
120
|
});
|
|
98
|
-
child.stderr.on('data', (chunk) => { err += chunk; });
|
|
99
|
-
child.on('close', (code) => resolve({ code, out, err }));
|
|
100
121
|
});
|
|
101
122
|
}
|
|
102
123
|
|
|
@@ -105,10 +126,13 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
105
126
|
const log = deps.log || ((line) => console.log(line));
|
|
106
127
|
const parallel = Math.max(1, Number(options.parallel) || 3);
|
|
107
128
|
const maxTicks = Math.max(1, Number(options.maxTicks) || 3);
|
|
129
|
+
// Every mission gets an explicit time limit. Without one, a number in the
|
|
130
|
+
// objective ("437 tests, 475s") was read as the mission's time budget.
|
|
131
|
+
const missionMinutes = Math.max(1, Number(options.missionMinutes) || 40);
|
|
108
132
|
const runner = options.runner || 'auto';
|
|
109
133
|
const actor = options.actor || 'mission-lead';
|
|
110
134
|
const ask = deps.ask || askOwner;
|
|
111
|
-
const cli = deps.cli || ((args) => runCli(args, { cwd: root }));
|
|
135
|
+
const cli = deps.cli || ((args, extra = {}) => runCli(args, { cwd: root, ...extra }));
|
|
112
136
|
const judge = deps.judge;
|
|
113
137
|
|
|
114
138
|
const view = (id) => getCampaign(store, id);
|
|
@@ -155,7 +179,8 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
155
179
|
const reserve = work.estimate_usd || defaultReserve(current);
|
|
156
180
|
const started = await cli([
|
|
157
181
|
'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
|
|
158
|
-
'--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
|
|
182
|
+
'--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
|
|
183
|
+
'--max-wall', String(missionMinutes * 60), '--json',
|
|
159
184
|
]);
|
|
160
185
|
if (started.code !== 0) {
|
|
161
186
|
const why = (started.out + started.err).trim().split('\n').pop();
|
|
@@ -164,8 +189,22 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
164
189
|
return { mission_id: work.mission_id, started: false };
|
|
165
190
|
}
|
|
166
191
|
log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
|
|
167
|
-
|
|
192
|
+
// The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
|
|
193
|
+
// the campaign's own output, so a screen watching the run can show the work.
|
|
194
|
+
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()}`); } });
|
|
168
195
|
const after = view(id).missions.find((m) => m.id === work.mission_id);
|
|
196
|
+
// A mission in a protected lane (billing, security, deploy, customer) pauses
|
|
197
|
+
// before its first tick. Ask a person once per lane instead of failing quietly.
|
|
198
|
+
const lane = after && after.status !== 'ended' ? pausedLane(`${ran.out || ''}\n${ran.err || ''}`) : null;
|
|
199
|
+
if (lane) {
|
|
200
|
+
if (!laneAskOpen(store.read(id), lane)) {
|
|
201
|
+
const askId = `a-${crypto.randomBytes(3).toString('hex')}`;
|
|
202
|
+
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);
|
|
203
|
+
}
|
|
204
|
+
await cli(['mission', 'stop', work.mission_id, '--reason', `campaign loop: waiting for a person to allow the ${lane} lane`]);
|
|
205
|
+
log(` ${work.mission_id} needs you: it touches ${lane}`);
|
|
206
|
+
return { mission_id: work.mission_id, started: true, outcome: null, needs: lane };
|
|
207
|
+
}
|
|
169
208
|
if (after && after.status !== 'ended') {
|
|
170
209
|
// The run stopped without finishing (tick cap, error): close it so its
|
|
171
210
|
// reserve comes back and its cost is counted.
|
|
@@ -239,4 +278,4 @@ function makeLoop(root, options = {}, deps = {}) {
|
|
|
239
278
|
return { step, run, propose };
|
|
240
279
|
}
|
|
241
280
|
|
|
242
|
-
module.exports = { askOwner, makeLoop, parseProposals, proposalPrompt };
|
|
281
|
+
module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, 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 };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One driver per campaign on a computer. Two `campaign run` loops on the same
|
|
4
|
+
// campaign each planned and started missions, so they raced for the same
|
|
5
|
+
// budget and edited the same files (2026-10-15, cmp-suite-3min). The lock is
|
|
6
|
+
// a file beside the store naming the process that holds it; a lock whose
|
|
7
|
+
// process is gone is stale and is taken over.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
function lockPath(home, campaignId) {
|
|
14
|
+
return path.join(home, 'campaigns', campaignId, 'run.lock');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function alive(pid) {
|
|
18
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return error.code === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readHolder(file) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Returns { ok: true, release } or { ok: false, holder } when a live run holds it.
|
|
36
|
+
function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostname(), cwd = process.cwd(), isAlive = alive } = {}) {
|
|
37
|
+
const file = lockPath(home, campaignId);
|
|
38
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
39
|
+
const mine = { pid, host, cwd, started_at: new Date().toISOString() };
|
|
40
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(file, `${JSON.stringify(mine)}\n`, { flag: 'wx' });
|
|
43
|
+
const release = () => {
|
|
44
|
+
const holder = readHolder(file);
|
|
45
|
+
if (holder && holder.pid === pid && holder.host === host) fs.rmSync(file, { force: true });
|
|
46
|
+
};
|
|
47
|
+
return { ok: true, release };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.code !== 'EEXIST') throw error;
|
|
50
|
+
}
|
|
51
|
+
const holder = readHolder(file);
|
|
52
|
+
if (holder && holder.host === host && isAlive(holder.pid)) return { ok: false, holder };
|
|
53
|
+
if (holder && holder.host !== host) return { ok: false, holder };
|
|
54
|
+
fs.rmSync(file, { force: true });
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, holder: readHolder(file) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { acquireRunLock, lockPath };
|