atris 3.74.28 → 3.75.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/skills/calendar/SKILL.md +9 -0
- package/atris/skills/drive/SKILL.md +7 -0
- package/atris/skills/email-agent/SKILL.md +7 -0
- package/atris/skills/magic-inbox/SKILL.md +7 -0
- package/atris/skills/slides/SKILL.md +7 -0
- package/bin/atris.js +14 -2
- package/commands/engine.js +2 -2
- package/commands/integrations.js +539 -54
- package/commands/mission.js +2 -2
- package/commands/rating.js +14 -4
- package/commands/worktree.js +39 -12
- package/lib/fleet.js +584 -81
- package/lib/gm-season.js +31 -9
- package/lib/known-commands.js +1 -1
- package/lib/release-kind.js +31 -7
- package/lib/task-db.js +185 -26
- package/lib/worktree-space.js +4 -45
- package/package.json +1 -1
package/lib/gm-season.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { atrisRating } = require('./atris-rating');
|
|
14
|
-
const { isProofNotSaved } = require('./release-kind');
|
|
14
|
+
const { isProofNotSaved, isCouldNotStart } = require('./release-kind');
|
|
15
15
|
|
|
16
16
|
const HOUR_MS = 60 * 60 * 1000;
|
|
17
17
|
const DAY_MS = 24 * HOUR_MS;
|
|
@@ -22,13 +22,14 @@ const ACTIVE_MISSION_STATUSES = new Set(['running', 'ready']);
|
|
|
22
22
|
const KEPT_ACTIONS = new Set(['accepted', 'merged']);
|
|
23
23
|
const FINISHED = new Set(['landed', 'dropped', 'sent_back', 'abandoned']);
|
|
24
24
|
const FORM_LETTER = { landed: 'k', dropped: 'd', sent_back: 'x', abandoned: 'a' };
|
|
25
|
-
const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned', not_needed: 'not needed', held: 'held' };
|
|
25
|
+
const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned', not_needed: 'not needed', held: 'held', could_not_start: 'unstarted' };
|
|
26
26
|
// A person has this long after a drop to say the job did not need doing.
|
|
27
27
|
const NOT_NEEDED_WINDOW_MS = 7 * DAY_MS;
|
|
28
28
|
|
|
29
29
|
// An outcome is one play:
|
|
30
30
|
// { event_id, task_id, member, title, kind, at, claimed_at, landed_by }
|
|
31
|
-
// kind is took | landed | dropped | sent_back | abandoned | not_needed | held
|
|
31
|
+
// kind is took | landed | dropped | sent_back | abandoned | not_needed | held |
|
|
32
|
+
// could_not_start.
|
|
32
33
|
// abandoned is a claim that ended archived without landing; a job still in
|
|
33
34
|
// review is pending and not counted. not_needed is a drop or abandon that
|
|
34
35
|
// someone other than the member later confirmed the job never needed; it is
|
|
@@ -36,8 +37,14 @@ const NOT_NEEDED_WINDOW_MS = 7 * DAY_MS;
|
|
|
36
37
|
// because the work is done and its pull request waits for a person, but the
|
|
37
38
|
// proof that says so could not be saved; the release says so in a field
|
|
38
39
|
// (lib/release-kind.js), never in its words. It is not finished either, so it
|
|
39
|
-
// is neither a landing nor a miss.
|
|
40
|
-
//
|
|
40
|
+
// is neither a landing nor a miss. could_not_start is a claim a flight let go
|
|
41
|
+
// before the engine ran, because the build machine could not start the job; the
|
|
42
|
+
// release says so in a field the same way. The member never touched the work, so
|
|
43
|
+
// it is not finished and not a miss either. The field checks itself: a claim the
|
|
44
|
+
// flight marked started (a build_started event after its claim, see
|
|
45
|
+
// markBuildStarted in task-db.js) had its engine asked, so a release that says
|
|
46
|
+
// could_not_start on it is a drop like any other. at and claimed_at are epoch
|
|
47
|
+
// ms; claimed_at may be null. landed_by is autoland | you, only on landed.
|
|
41
48
|
|
|
42
49
|
function byTimeAsc(a, b) {
|
|
43
50
|
return Number(a.at || 0) - Number(b.at || 0);
|
|
@@ -50,7 +57,7 @@ function isFinished(outcome) {
|
|
|
50
57
|
// jobs = landed + dropped + sent back + abandoned in the window, the same
|
|
51
58
|
// count the rating divides by. Jobs still in review are pending.
|
|
52
59
|
function tally(outcomes) {
|
|
53
|
-
const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0, not_needed: 0, held: 0 };
|
|
60
|
+
const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0, not_needed: 0, held: 0, could_not_start: 0 };
|
|
54
61
|
for (const o of outcomes || []) {
|
|
55
62
|
if (Object.prototype.hasOwnProperty.call(out, o.kind)) out[o.kind] += 1;
|
|
56
63
|
}
|
|
@@ -330,8 +337,13 @@ function outcomeFromEvent(row) {
|
|
|
330
337
|
member = row.actor || payload.claimed_by;
|
|
331
338
|
} else if (row.event_type === 'claim_released') {
|
|
332
339
|
// A release that carries the proof-not-saved fields is work waiting on a person,
|
|
333
|
-
// not a drop.
|
|
334
|
-
|
|
340
|
+
// not a drop. One that carries the could-not-start kind was let go before the
|
|
341
|
+
// engine ran, so it is not a drop either, unless its claim was marked started
|
|
342
|
+
// (engine_started): then the kind is false and it is a drop. Only the fields say
|
|
343
|
+
// so: the same words with no fields stay a drop.
|
|
344
|
+
kind = isProofNotSaved(payload) ? 'held'
|
|
345
|
+
: isCouldNotStart(payload) && Number(row.engine_started) !== 1 ? 'could_not_start'
|
|
346
|
+
: 'dropped';
|
|
335
347
|
member = payload.previous_claimed_by || payload.released_by || row.actor;
|
|
336
348
|
} else if (row.event_type === 'claim_expired') {
|
|
337
349
|
kind = 'dropped';
|
|
@@ -420,7 +432,11 @@ function outcomesFromEvents(rows, since = -Infinity) {
|
|
|
420
432
|
const ended = ['claim_released', 'claim_expired', 'completed', 'reopened', 'archived'].includes(row.event_type);
|
|
421
433
|
if (!play) { if (ended) closed.add(key); continue; }
|
|
422
434
|
if (play.kind === 'took') { plays.push(play); continue; }
|
|
423
|
-
|
|
435
|
+
// A release that another member's claim follows within two minutes reads as a hand-over, and is left out. That
|
|
436
|
+
// is a rule about old history, whose releases carry no mark, and it holds for them as it always did. A release
|
|
437
|
+
// on a claim the engine started (engine_started) is a real miss whoever claims next, and one that could not
|
|
438
|
+
// start has its own line: neither is a hand-over, so neither is erased.
|
|
439
|
+
if (row.event_type === 'claim_released' && Number(row.engine_started) !== 1 && play.kind !== 'could_not_start') {
|
|
424
440
|
let next = null;
|
|
425
441
|
for (let j = i + 1; j < ordered.length && ordered[j].task_id === row.task_id; j++) {
|
|
426
442
|
if (ordered[j].event_type === 'claimed') { next = ordered[j]; break; }
|
|
@@ -555,6 +571,12 @@ function playEventsSql(extraWhere = '') {
|
|
|
555
571
|
SELECT e.event_id, e.task_id, e.workspace_root, e.event_type, e.actor, e.payload, e.created_at, e.version,
|
|
556
572
|
t.title, t.claimed_by, t.claimed_at,
|
|
557
573
|
c.event_id AS claim_event_id, c.created_at AS claim_event_at, c.actor AS claim_actor,
|
|
574
|
+
CASE WHEN e.event_type = 'claim_released' AND c.event_id IS NOT NULL AND EXISTS (
|
|
575
|
+
SELECT 1 FROM task_events b
|
|
576
|
+
WHERE b.task_id = e.task_id AND b.version > c.version AND b.version < e.version
|
|
577
|
+
-- the flight marks a claim started right before it asks the assigned engine
|
|
578
|
+
AND b.event_type = 'build_started'
|
|
579
|
+
) THEN 1 ELSE 0 END AS engine_started,
|
|
558
580
|
CASE WHEN e.event_type = 'archived' AND c.event_id IS NOT NULL AND NOT EXISTS (
|
|
559
581
|
SELECT 1 FROM task_events x
|
|
560
582
|
WHERE x.task_id = e.task_id AND x.version > c.version AND x.version < e.version
|
package/lib/known-commands.js
CHANGED
|
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
|
|
|
4
4
|
'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
|
|
6
6
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'scorecard', 'rating', 'sync-checkout', 'zen',
|
|
7
|
-
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
7
|
+
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
8
|
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
|
|
9
9
|
|
|
10
10
|
// Damerau-Levenshtein edit distance between two short strings. Counts an
|
package/lib/release-kind.js
CHANGED
|
@@ -2,16 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
// Why a claim was let go, as a field the rating can read without reading words.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// Two kinds exist. Both ride on the release event as fields; the reason text is
|
|
6
|
+
// only for people, and nothing ever parses it.
|
|
7
|
+
//
|
|
8
|
+
// proof_not_saved. The work is done and its pull request is open for a person to
|
|
6
9
|
// land, but the proof that says so could not be saved. The builder did its part,
|
|
7
|
-
// so the release is neither a landing nor a drop.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
+
// so the release is neither a landing nor a drop. A release says this by passing
|
|
11
|
+
// the kind and the pull request together. The task db refuses half of a marker,
|
|
12
|
+
// and the rating reads a release as this kind only when both are there.
|
|
13
|
+
//
|
|
14
|
+
// could_not_start. The flight let the claim go before the engine ran, because the
|
|
15
|
+
// build machine could not start the job (no room on the disk, a build copy that
|
|
16
|
+
// would not open, a lease that would not set, a brief that would not save). The
|
|
17
|
+
// builder never touched the work, so the release is neither a drop nor a finished
|
|
18
|
+
// job. The kind goes alone, with no pull request, and only the flights pass it.
|
|
10
19
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
20
|
+
// The kind checks itself. A builder shares the task store with the flight, so
|
|
21
|
+
// the store and the rating take it only for a claim whose engine was never
|
|
22
|
+
// asked. Right before it asks the assigned engine, the flight writes a
|
|
23
|
+
// build_started event on the claim (see markBuildStarted in task-db.js). A
|
|
24
|
+
// release that carries could_not_start on a claim with that event is an
|
|
25
|
+
// ordinary release, and the rating reads it as a drop. Scout and prep passes
|
|
26
|
+
// run before the mark, so they never count as the build starting.
|
|
13
27
|
|
|
14
28
|
const PROOF_NOT_SAVED = 'proof_not_saved';
|
|
29
|
+
const COULD_NOT_START = 'could_not_start';
|
|
30
|
+
// The event type of that mark.
|
|
31
|
+
const BUILD_STARTED = 'build_started';
|
|
15
32
|
|
|
16
33
|
// True for a release event payload that carries the kind and an open pull
|
|
17
34
|
// request it can point at. Anything less is an ordinary release.
|
|
@@ -24,4 +41,11 @@ function isProofNotSaved(payload) {
|
|
|
24
41
|
return (Number.isInteger(pr.number) && pr.number > 0) || /^https?:\/\//i.test(String(pr.url || '').trim());
|
|
25
42
|
}
|
|
26
43
|
|
|
27
|
-
|
|
44
|
+
// True for a release event payload that carries the could-not-start kind.
|
|
45
|
+
// Anything else, including the same words with no field, is an ordinary release.
|
|
46
|
+
function isCouldNotStart(payload) {
|
|
47
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
48
|
+
return payload.kind === COULD_NOT_START;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { PROOF_NOT_SAVED, COULD_NOT_START, BUILD_STARTED, isProofNotSaved, isCouldNotStart };
|
package/lib/task-db.js
CHANGED
|
@@ -27,7 +27,7 @@ const path = require('path');
|
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
const { DatabaseSync } = require('node:sqlite');
|
|
29
29
|
const reviewIntegrity = require('./review-integrity');
|
|
30
|
-
const { PROOF_NOT_SAVED } = require('./release-kind');
|
|
30
|
+
const { PROOF_NOT_SAVED, COULD_NOT_START, BUILD_STARTED } = require('./release-kind');
|
|
31
31
|
const dailyLog = require('./daily-log');
|
|
32
32
|
const { isGenericScratchRoot } = require('./scratch-root');
|
|
33
33
|
const { isDecisionTask } = require('./task-decision');
|
|
@@ -593,9 +593,50 @@ function workspaceHead(workspaceRoot) {
|
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
596
|
+
// One row change and its history entry are one transaction: both are in the store or neither is, so nobody can see,
|
|
597
|
+
// or compare against, a job whose row says one thing and whose history says another, and a history write that fails
|
|
598
|
+
// takes the row change back with it. BEGIN IMMEDIATE takes the write lock first, so what the writer read is still
|
|
599
|
+
// true when it writes. A caller already inside a transaction of its own joins that one (db.isTransaction says so on
|
|
600
|
+
// the Node versions that have it; on the others SQLite refuses to begin inside one, and that refusal says the same).
|
|
601
|
+
function inWriteTransaction(db, write) {
|
|
602
|
+
if (db.isTransaction) return write();
|
|
603
|
+
try {
|
|
604
|
+
withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
|
|
605
|
+
} catch (err) {
|
|
606
|
+
if (/within a transaction/i.test(String(err && err.message))) return write();
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
let result;
|
|
610
|
+
try {
|
|
611
|
+
result = write();
|
|
612
|
+
db.exec('COMMIT');
|
|
613
|
+
} catch (err) {
|
|
614
|
+
try { db.exec('ROLLBACK'); } catch { /* the transaction is already gone */ }
|
|
615
|
+
throw err;
|
|
616
|
+
}
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The part of a write that holds it to one claim. The caller names a claim by its claimed event (bound twice, once
|
|
621
|
+
// for the NULL test, which leaves a write that names none as it always was). The write goes through only when that
|
|
622
|
+
// event is the job's latest claimed event and is no older than the row's own claim time, which is how an older CLI,
|
|
623
|
+
// that saves the row first and its event a moment later, shows a claim that is not here yet. It sits inside the
|
|
624
|
+
// write itself, one statement, so a claim that lands after the caller's last look is refused too, and it reads the
|
|
625
|
+
// claim by its event, not by a time, which two claims can share.
|
|
626
|
+
const NAMED_CLAIM_SQL = `AND (? IS NULL OR EXISTS (
|
|
627
|
+
SELECT 1 FROM task_events e
|
|
628
|
+
WHERE e.task_id = tasks.id
|
|
629
|
+
AND e.event_type = 'claimed'
|
|
630
|
+
AND e.event_id = ?
|
|
631
|
+
AND (tasks.claimed_at IS NULL OR e.created_at >= tasks.claimed_at)
|
|
632
|
+
AND e.version = (SELECT MAX(version) FROM task_events WHERE task_id = tasks.id AND event_type = 'claimed')))`;
|
|
633
|
+
|
|
634
|
+
// Atomic claim. Returns { claimed: true, row, event } only if THIS call won the row; event is the claimed event
|
|
635
|
+
// this claim wrote, the one name for it (see latestClaim). Race-safe via single UPDATE with WHERE status='open'
|
|
636
|
+
// guard. SQLite serializes writes; busy_timeout absorbs contention. Caller must check `.claimed`.
|
|
637
|
+
//
|
|
638
|
+
// The row and its claimed event are written in one transaction. Before, the row was claimed first and the event
|
|
639
|
+
// followed a git call later, and in that gap a late release naming the claim before could pass for the new one.
|
|
599
640
|
function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
|
|
600
641
|
if (!id) throw new Error('id required');
|
|
601
642
|
if (!claimedBy) throw new Error('claimedBy required');
|
|
@@ -611,27 +652,42 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
|
|
|
611
652
|
AND status = 'open'
|
|
612
653
|
`);
|
|
613
654
|
const lease = leaseMinutes == null ? null : JSON.stringify({ until: new Date(now + leaseMinutes * 60000).toISOString(), minutes: leaseMinutes, holder: claimedBy, pid: leasePid });
|
|
614
|
-
const
|
|
615
|
-
|
|
655
|
+
const won = inWriteTransaction(db, () => {
|
|
656
|
+
const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
|
|
657
|
+
if (result.changes !== 1) return null;
|
|
616
658
|
let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
617
|
-
//
|
|
659
|
+
// The commit the workspace sits on is read here, with the write lock held, and only for a claim that won. A claim
|
|
660
|
+
// that has to wait for the lock is made when it gets it, and a commit that landed in the checkout while it waited
|
|
661
|
+
// is part of what was there before the job began: read earlier, the start would name a commit older than the claim,
|
|
662
|
+
// and a job that made no commit could be credited with the one that landed (lib/landing-proof.js). A git that is
|
|
663
|
+
// slow to answer holds the lock for no longer than workspaceHead lets it wait.
|
|
618
664
|
const startCommit = workspaceHead(row.workspace_root);
|
|
665
|
+
// First claim wins: a re-claim after a revision keeps the original start.
|
|
619
666
|
const claimedMetadata = row.metadata ? safeJSON(row.metadata) || {} : {};
|
|
620
667
|
if (startCommit && !claimedMetadata.start_commit) {
|
|
621
668
|
claimedMetadata.start_commit = startCommit;
|
|
622
669
|
withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(JSON.stringify(claimedMetadata), id));
|
|
623
670
|
row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
624
671
|
}
|
|
625
|
-
appendTaskEvent(db, {
|
|
672
|
+
const event = appendTaskEvent(db, {
|
|
626
673
|
taskId: id,
|
|
627
674
|
workspaceRoot: row.workspace_root,
|
|
628
675
|
actor: claimedBy,
|
|
629
676
|
eventType: 'claimed',
|
|
630
677
|
payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
|
|
631
678
|
});
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
679
|
+
return { row, event };
|
|
680
|
+
});
|
|
681
|
+
if (won) {
|
|
682
|
+
const enriched = { ...won.row, metadata: won.row.metadata ? safeJSON(won.row.metadata) : null };
|
|
683
|
+
// The work log is a note for people, written once the claim is saved. A note that cannot be written must not
|
|
684
|
+
// turn a claim that landed into an error: the caller would not learn which claim is its own.
|
|
685
|
+
try {
|
|
686
|
+
appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (process.env.ATRIS_DEBUG) process.stderr.write(`claim log failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
|
|
689
|
+
}
|
|
690
|
+
return { claimed: true, row: enriched, event: won.event };
|
|
635
691
|
}
|
|
636
692
|
// Either id doesn't exist or status != 'open'. Tell the caller which.
|
|
637
693
|
const row = db.prepare('SELECT id, status, claimed_by FROM tasks WHERE id = ?').get(id);
|
|
@@ -639,14 +695,19 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
|
|
|
639
695
|
return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
|
|
640
696
|
}
|
|
641
697
|
|
|
642
|
-
// Lease updates are guarded by the holder and do not create activity events.
|
|
643
|
-
|
|
698
|
+
// Lease updates are guarded by the holder and do not create activity events. A flight also names the claim it
|
|
699
|
+
// made (claimEventId, see NAMED_CLAIM_SQL): the lease is written, renewed, or cleared only while the job is
|
|
700
|
+
// still held under that claim, in the same statement, so a flight that lost its job cannot replace or clear the
|
|
701
|
+
// lease of the claim that came after it, even under the same builder name. One statement is one transaction,
|
|
702
|
+
// and a lease has no history entry, so there is nothing more to keep together.
|
|
703
|
+
function setLease(db, { id, holder, minutes = 60, pid = process.pid, now = Date.now(), claimEventId }) {
|
|
644
704
|
const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
|
|
645
705
|
const row = getTask(db, id);
|
|
646
706
|
if (!row || row.metadata_unreadable || row.status !== 'claimed' || row.claimed_by !== holder) return false;
|
|
707
|
+
const named = claimEventId ? String(claimEventId) : null;
|
|
647
708
|
const metadata = { ...(row.metadata || {}), lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder, pid } };
|
|
648
|
-
return withBusyRetry(() => db.prepare(
|
|
649
|
-
.run(JSON.stringify(metadata), id, holder, raw.metadata)).changes === 1;
|
|
709
|
+
return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
|
|
710
|
+
.run(JSON.stringify(metadata), id, holder, raw.metadata, named, named)).changes === 1;
|
|
650
711
|
}
|
|
651
712
|
|
|
652
713
|
function renewLease(db, options) {
|
|
@@ -654,21 +715,23 @@ function renewLease(db, options) {
|
|
|
654
715
|
const metadata = row && row.metadata ? safeJSON(row.metadata) : null;
|
|
655
716
|
const lease = metadata && metadata.lease;
|
|
656
717
|
if (!row || row.status !== 'claimed' || row.claimed_by !== options.holder || !lease || lease.holder !== options.holder || (options.pid != null && lease.pid !== options.pid)) return false;
|
|
718
|
+
const named = options.claimEventId ? String(options.claimEventId) : null;
|
|
657
719
|
const minutes = options.minutes ?? lease.minutes;
|
|
658
720
|
const now = options.now ?? Date.now();
|
|
659
721
|
const updated = { ...metadata, lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder: options.holder, pid: options.pid ?? lease.pid } };
|
|
660
|
-
return withBusyRetry(() => db.prepare(
|
|
661
|
-
.run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata)).changes === 1;
|
|
722
|
+
return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND claimed_at IS ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
|
|
723
|
+
.run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata, named, named)).changes === 1;
|
|
662
724
|
}
|
|
663
725
|
|
|
664
|
-
function clearLease(db, { id, holder }) {
|
|
726
|
+
function clearLease(db, { id, holder, claimEventId }) {
|
|
665
727
|
const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
|
|
666
728
|
const row = getTask(db, id);
|
|
667
729
|
if (!row || !row.metadata || !row.metadata.lease || row.metadata.lease.holder !== holder) return false;
|
|
730
|
+
const named = claimEventId ? String(claimEventId) : null;
|
|
668
731
|
const metadata = { ...row.metadata };
|
|
669
732
|
delete metadata.lease;
|
|
670
|
-
return withBusyRetry(() => db.prepare(
|
|
671
|
-
.run(JSON.stringify(metadata), id, raw.metadata)).changes === 1;
|
|
733
|
+
return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
|
|
734
|
+
.run(JSON.stringify(metadata), id, raw.metadata, named, named)).changes === 1;
|
|
672
735
|
}
|
|
673
736
|
|
|
674
737
|
function pidAlive(pid) {
|
|
@@ -743,23 +806,103 @@ function sweepExpiredLeases() {
|
|
|
743
806
|
// line. A release that must not read as a drop (the work is done and its pull
|
|
744
807
|
// request waits for a person, only the proof could not be saved) also carries a
|
|
745
808
|
// kind and that pull request as fields, so the rating never has to read words.
|
|
746
|
-
// Only the
|
|
809
|
+
// Only the flights pass them, in their own process; `atris task release`
|
|
747
810
|
// takes neither, so a worker cannot excuse its own miss with a flag. The two go
|
|
748
|
-
// together or not at all; half a marker leaves the claim held.
|
|
811
|
+
// together or not at all; half a marker leaves the claim held. One kind goes
|
|
812
|
+
// alone: could_not_start, for a claim let go before the engine ran, takes no
|
|
813
|
+
// pull request and is refused with one.
|
|
749
814
|
const RELEASE_REASON_MAX = 300;
|
|
750
815
|
|
|
751
816
|
function releaseMarker(kind, pullRequest) {
|
|
752
817
|
const named = kind !== undefined && kind !== null && String(kind).trim() !== '';
|
|
753
818
|
if (!named && !pullRequest) return { marker: null };
|
|
819
|
+
if (named && String(kind).trim() === COULD_NOT_START && !pullRequest) return { marker: { kind: COULD_NOT_START } };
|
|
754
820
|
const open = cleanOpenPullRequest(pullRequest);
|
|
755
821
|
if (!named || String(kind).trim() !== PROOF_NOT_SAVED || !open) return { invalid: true };
|
|
756
822
|
return { marker: { kind: PROOF_NOT_SAVED, pull_request: open } };
|
|
757
823
|
}
|
|
758
824
|
|
|
759
|
-
|
|
825
|
+
// The claim a job is held under now: its latest claimed event. The event id names that one claim, so a
|
|
826
|
+
// flight that remembers it can tell later whether the job is still held under the claim it made, or was
|
|
827
|
+
// let go and claimed again, even under the same builder name.
|
|
828
|
+
function latestClaim(db, id) {
|
|
829
|
+
return db.prepare("SELECT event_id, version, actor, created_at FROM task_events WHERE task_id = ? AND event_type = 'claimed' ORDER BY version DESC LIMIT 1").get(id) || null;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// True when the claim event the caller names is the claim the job is held under now. claimTask writes the row and
|
|
833
|
+
// its claimed event in one transaction, so here the two always agree. A claim saved by an older CLI wrote the row
|
|
834
|
+
// first and its event a moment later, and in that gap the latest event is still the claim before it: an event
|
|
835
|
+
// written before the row's own claim time is that older claim, not the one the row holds.
|
|
836
|
+
function claimIsCurrent(row, claim, claimEventId) {
|
|
837
|
+
if (!claim || claim.event_id !== claimEventId) return false;
|
|
838
|
+
return row.claimed_at == null || Number(claim.created_at) >= Number(row.claimed_at);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// True when the engine was asked on the job's current claim: a build_started event sits after its latest
|
|
842
|
+
// claimed event (see markBuildStarted).
|
|
843
|
+
function claimMarkedStarted(db, id) {
|
|
844
|
+
const claim = latestClaim(db, id);
|
|
845
|
+
return Boolean(claim) && Boolean(db.prepare('SELECT 1 AS hit FROM task_events WHERE task_id = ? AND event_type = ? AND version > ? LIMIT 1').get(id, BUILD_STARTED, claim.version));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// A flight writes this right before it asks the assigned engine, on the claim it made. It is what lets the
|
|
849
|
+
// store and the rating tell a claim let go before the engine ran from one let go after it: a release that
|
|
850
|
+
// says could_not_start on a marked claim is an ordinary release (see releaseTask). Only the holder can mark,
|
|
851
|
+
// and claimEventId, when given, names the one claim the caller made, so a run that lost its claim cannot mark
|
|
852
|
+
// the next run's. Marking twice adds nothing. A refusal writes nothing.
|
|
853
|
+
function markBuildStarted(db, { id, actor, claimEventId }) {
|
|
854
|
+
if (!id) throw new Error('id required');
|
|
855
|
+
if (!actor) throw new Error('actor required');
|
|
856
|
+
withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
|
|
857
|
+
try {
|
|
858
|
+
const row = getTask(db, id);
|
|
859
|
+
let result;
|
|
860
|
+
if (!row) result = { marked: false, reason: 'not_found' };
|
|
861
|
+
else if (row.status !== 'claimed') result = { marked: false, reason: `already_${row.status}`, claimed_by: row.claimed_by || null };
|
|
862
|
+
else if (row.claimed_by !== actor) result = { marked: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
|
|
863
|
+
else {
|
|
864
|
+
const claim = latestClaim(db, id);
|
|
865
|
+
if (!claim) result = { marked: false, reason: 'no_claim_event' };
|
|
866
|
+
else if (claimEventId && !claimIsCurrent(row, claim, claimEventId)) result = { marked: false, reason: 'claim_changed', claimed_by: row.claimed_by || null };
|
|
867
|
+
else if (claimMarkedStarted(db, id)) result = { marked: true, already: true };
|
|
868
|
+
else {
|
|
869
|
+
result = {
|
|
870
|
+
marked: true,
|
|
871
|
+
event: appendTaskEvent(db, {
|
|
872
|
+
taskId: id,
|
|
873
|
+
workspaceRoot: row.workspace_root,
|
|
874
|
+
actor,
|
|
875
|
+
eventType: BUILD_STARTED,
|
|
876
|
+
payload: { claimed_by: actor, claim_event_id: claim.event_id },
|
|
877
|
+
}),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
db.exec('COMMIT');
|
|
882
|
+
return result;
|
|
883
|
+
} catch (err) { db.exec('ROLLBACK'); throw err; }
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// claimEventId, when given, names the one claim the caller made (see latestClaim). A job that was let go
|
|
887
|
+
// and claimed again since, by anyone, is not the caller's to release: nothing is written. The check is part of
|
|
888
|
+
// the write itself, one statement (see NAMED_CLAIM_SQL), so a claim that lands between the caller's last look and
|
|
889
|
+
// the release is refused too, and it reads the claim by its event, not by a time, which two claims can share.
|
|
890
|
+
//
|
|
891
|
+
// The look, the row write, and the claim_released entry are one transaction (see inWriteTransaction): a history
|
|
892
|
+
// entry that cannot be written takes the release back with it, so a job is never left open with no record of how,
|
|
893
|
+
// and the check cannot pass against a claim whose own entry is not in the store yet.
|
|
894
|
+
//
|
|
895
|
+
// The could-not-start kind checks itself. It is taken only for a claim whose engine was never asked. On a
|
|
896
|
+
// claim marked started (see markBuildStarted) the release goes through as an ordinary release with no kind,
|
|
897
|
+
// and is not refused, so the claim is never left held.
|
|
898
|
+
function releaseTask(db, release) {
|
|
899
|
+
return inWriteTransaction(db, () => releaseTaskInTransaction(db, release));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function releaseTaskInTransaction(db, { id, actor, expectedUpdatedAt, reason, kind, pullRequest, claimEventId }) {
|
|
760
903
|
if (!id) throw new Error('id required');
|
|
761
904
|
if (!actor) throw new Error('actor required');
|
|
762
|
-
const { marker, invalid } = releaseMarker(kind, pullRequest);
|
|
905
|
+
const { marker: asked, invalid } = releaseMarker(kind, pullRequest);
|
|
763
906
|
if (invalid) return { released: false, reason: 'invalid_release_marker' };
|
|
764
907
|
const row = getTask(db, id);
|
|
765
908
|
if (!row) return { released: false, reason: 'not_found' };
|
|
@@ -769,8 +912,10 @@ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullReque
|
|
|
769
912
|
if (row.claimed_by !== actor) {
|
|
770
913
|
return { released: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
|
|
771
914
|
}
|
|
915
|
+
const marker = asked && asked.kind === COULD_NOT_START && claimMarkedStarted(db, id) ? null : asked;
|
|
772
916
|
const releaseReason = compactEpisodeText(reason, RELEASE_REASON_MAX);
|
|
773
917
|
const now = Date.now();
|
|
918
|
+
const named = claimEventId ? String(claimEventId) : null;
|
|
774
919
|
const result = withBusyRetry(() => db.prepare(`
|
|
775
920
|
UPDATE tasks
|
|
776
921
|
SET status = 'open',
|
|
@@ -781,9 +926,21 @@ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullReque
|
|
|
781
926
|
WHERE id = ?
|
|
782
927
|
AND status = 'claimed'
|
|
783
928
|
AND claimed_by = ?
|
|
929
|
+
AND claimed_at IS ?
|
|
784
930
|
AND (? IS NULL OR updated_at = ?)
|
|
785
|
-
|
|
786
|
-
|
|
931
|
+
${NAMED_CLAIM_SQL}
|
|
932
|
+
`).run(now, id, actor, row.claimed_at ?? null, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null, named, named));
|
|
933
|
+
if (result.changes !== 1) {
|
|
934
|
+
// The job changed between the look and the write. A caller that named its claim is told what the job is now.
|
|
935
|
+
if (named) {
|
|
936
|
+
const current = getTask(db, id);
|
|
937
|
+
if (!current) return { released: false, reason: 'not_found' };
|
|
938
|
+
if (current.status !== 'claimed') return { released: false, reason: `already_${current.status}`, claimed_by: current.claimed_by || null };
|
|
939
|
+
if (current.claimed_by !== actor) return { released: false, reason: 'held_by_other', claimed_by: current.claimed_by || null };
|
|
940
|
+
if (!claimIsCurrent(current, latestClaim(db, id), named)) return { released: false, reason: 'claim_changed', claimed_by: current.claimed_by || null };
|
|
941
|
+
}
|
|
942
|
+
return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
|
|
943
|
+
}
|
|
787
944
|
const updated = getTask(db, id);
|
|
788
945
|
const event = appendTaskEvent(db, {
|
|
789
946
|
taskId: id,
|
|
@@ -2584,6 +2741,8 @@ module.exports = {
|
|
|
2584
2741
|
previewExpiredLeases,
|
|
2585
2742
|
sweepExpiredLeases,
|
|
2586
2743
|
releaseTask,
|
|
2744
|
+
latestClaim,
|
|
2745
|
+
markBuildStarted,
|
|
2587
2746
|
reopenTask,
|
|
2588
2747
|
backlogTask,
|
|
2589
2748
|
clearPlanTasks,
|
package/lib/worktree-space.js
CHANGED
|
@@ -26,8 +26,6 @@ const GB = 1e9;
|
|
|
26
26
|
// fail (swap, updates, builds), so start refuses to add another copy.
|
|
27
27
|
const DEFAULT_REAP_BELOW_GB = 25;
|
|
28
28
|
const DEFAULT_REFUSE_BELOW_GB = 10;
|
|
29
|
-
const DEPS_CACHE_KEEP = 3;
|
|
30
|
-
const PARTIAL_MAX_AGE_MS = 60 * 60 * 1000;
|
|
31
29
|
const AGENT_COPIES_DIR = '.agent-worktrees';
|
|
32
30
|
const DEPS_DIR = '.deps';
|
|
33
31
|
const CLONE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -224,6 +222,8 @@ function ensureCachedInstall({ workspace, repoName, hash, lockFile, sourceDir, c
|
|
|
224
222
|
const entry = depsCacheEntry(workspace, repoName, hash);
|
|
225
223
|
const cached = path.join(entry, 'node_modules');
|
|
226
224
|
if (installedMatchesLock(lockFile, cached)) return cached;
|
|
225
|
+
// A mismatched existing entry may contain local changes. Do not replace it.
|
|
226
|
+
if (pathExists(entry)) return null;
|
|
227
227
|
if (!sourceDir || !installedMatchesLock(lockFile, sourceDir)) return null;
|
|
228
228
|
const partial = path.join(depsRepoDir(workspace, repoName), `.partial-${hash}-${process.pid}-${Date.now()}`);
|
|
229
229
|
try {
|
|
@@ -233,8 +233,6 @@ function ensureCachedInstall({ workspace, repoName, hash, lockFile, sourceDir, c
|
|
|
233
233
|
// Build caches are not part of the install and only grow the store.
|
|
234
234
|
removeTree(path.join(partial, 'node_modules', '.cache'));
|
|
235
235
|
if (!installedMatchesLock(lockFile, path.join(partial, 'node_modules'))) return null;
|
|
236
|
-
// An entry that no longer matches its own lockfile was changed by hand.
|
|
237
|
-
if (pathExists(entry) && !installedMatchesLock(lockFile, cached)) removeTree(entry);
|
|
238
236
|
try {
|
|
239
237
|
fs.renameSync(partial, entry);
|
|
240
238
|
} catch {
|
|
@@ -253,49 +251,12 @@ function touch(target) {
|
|
|
253
251
|
} catch {}
|
|
254
252
|
}
|
|
255
253
|
|
|
256
|
-
// Keeps the `keep` most recently used installs per repo and drops partial
|
|
257
|
-
// entries a crashed start left behind.
|
|
258
|
-
function pruneDepsCache({ workspace, repoName, keep = DEPS_CACHE_KEEP, now = Date.now() } = {}) {
|
|
259
|
-
const dir = depsRepoDir(workspace, repoName);
|
|
260
|
-
let names = [];
|
|
261
|
-
try {
|
|
262
|
-
names = fs.readdirSync(dir);
|
|
263
|
-
} catch {
|
|
264
|
-
return [];
|
|
265
|
-
}
|
|
266
|
-
const removed = [];
|
|
267
|
-
const entries = [];
|
|
268
|
-
for (const name of names) {
|
|
269
|
-
const full = path.join(dir, name);
|
|
270
|
-
let mtimeMs = 0;
|
|
271
|
-
try {
|
|
272
|
-
mtimeMs = fs.statSync(full).mtimeMs;
|
|
273
|
-
} catch {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (name.startsWith('.partial-')) {
|
|
277
|
-
if (now - mtimeMs > PARTIAL_MAX_AGE_MS) {
|
|
278
|
-
removeTree(full);
|
|
279
|
-
removed.push(full);
|
|
280
|
-
}
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
if (!name.startsWith('.')) entries.push({ full, mtimeMs });
|
|
284
|
-
}
|
|
285
|
-
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
286
|
-
for (const old of entries.slice(keep)) {
|
|
287
|
-
removeTree(old.full);
|
|
288
|
-
removed.push(old.full);
|
|
289
|
-
}
|
|
290
|
-
return removed;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
254
|
// Gives a new build copy its node_modules as a clone of a matching install,
|
|
294
255
|
// so the agent skips `npm ci`. Looks in the shared store first, then in the
|
|
295
256
|
// main checkout and other copies of the same repo. Never fails the start:
|
|
296
257
|
// any miss returns { status: 'skipped', reason } and the agent installs as
|
|
297
258
|
// before.
|
|
298
|
-
function seedNodeModules({ target, workspace, repoName, sources = [], clone = cloneTree,
|
|
259
|
+
function seedNodeModules({ target, workspace, repoName, sources = [], clone = cloneTree, isIgnored = () => true } = {}) {
|
|
299
260
|
const startedAt = Date.now();
|
|
300
261
|
const result = (status, reason, extra = {}) => ({ status, reason, ms: Date.now() - startedAt, ...extra });
|
|
301
262
|
const lockFile = path.join(target, 'package-lock.json');
|
|
@@ -327,14 +288,13 @@ function seedNodeModules({ target, workspace, repoName, sources = [], clone = cl
|
|
|
327
288
|
}
|
|
328
289
|
}
|
|
329
290
|
if (!cached) return result('skipped', 'no matching install to clone yet; run npm ci, and later copies will clone it', { hash });
|
|
330
|
-
//
|
|
291
|
+
// Record use of the entry before cloning it.
|
|
331
292
|
touch(path.dirname(cached));
|
|
332
293
|
const cloned = clone(cached, modules);
|
|
333
294
|
if (!cloned.ok || !installedMatchesLock(lockFile, modules)) {
|
|
334
295
|
removeTree(modules);
|
|
335
296
|
return result('skipped', `clone failed: ${cloned.error || 'install did not match after cloning'}`, { hash });
|
|
336
297
|
}
|
|
337
|
-
pruneDepsCache({ workspace, repoName, keep });
|
|
338
298
|
return result('cloned', '', { hash, method: cloned.method, source: cached });
|
|
339
299
|
}
|
|
340
300
|
|
|
@@ -351,7 +311,6 @@ module.exports = {
|
|
|
351
311
|
isInside,
|
|
352
312
|
isScratchPath,
|
|
353
313
|
lockHash,
|
|
354
|
-
pruneDepsCache,
|
|
355
314
|
seedNodeModules,
|
|
356
315
|
spaceLimits,
|
|
357
316
|
};
|
package/package.json
CHANGED