atris 3.78.0 → 3.80.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.
@@ -234,15 +234,16 @@ const GIT_REPOSITORY_VARIABLES = [
234
234
 
235
235
  // Which repository a workspace came from: the owner and name its origin remote points at, asked of
236
236
  // git in the workspace's own folder. Null when git cannot say (no folder, no origin) or the remote
237
- // does not read as a GitHub repository.
238
- function originRepoOf(root, { run = spawnSync } = {}) {
237
+ // does not read as a GitHub repository. `run` and `timeoutMs` (10 seconds unless told) are for the
238
+ // caller that holds git to a limit of its own, and for tests.
239
+ function originRepoOf(root, { run = spawnSync, timeoutMs = 10000 } = {}) {
239
240
  if (!root) return null;
240
241
  const env = { ...process.env };
241
242
  for (const key of GIT_REPOSITORY_VARIABLES) delete env[key];
242
243
  const done = run('git', ['-C', String(root), 'remote', 'get-url', 'origin'], {
243
244
  encoding: 'utf8',
244
245
  env,
245
- timeout: 10000,
246
+ timeout: timeoutMs,
246
247
  killSignal: 'SIGKILL',
247
248
  stdio: ['ignore', 'pipe', 'ignore'],
248
249
  }) || {};
@@ -289,6 +290,14 @@ function leftAloneDetail(reason) {
289
290
  return 'it changed while GitHub was being asked, so it was left alone';
290
291
  }
291
292
 
293
+ // The jobs among `rows` that wait on a pull request, oldest first (by when they were made, then by row id).
294
+ // The order is the order the sweep goes through them in.
295
+ function heldJobsAmong(taskStore, rows) {
296
+ return rows
297
+ .filter((row) => row.status === 'review' && taskStore.heldPullRequestOf(row))
298
+ .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id).localeCompare(String(b.id)));
299
+ }
300
+
292
301
  // Check every held job against GitHub and, unless dryRun, write down what happened.
293
302
  //
294
303
  // taskStore lib/task-db
@@ -298,6 +307,21 @@ function leftAloneDetail(reason) {
298
307
  // ask how to ask GitHub about a link (askGitHub); asked once per link per run
299
308
  // askBranch how to ask GitHub for a repository's default branch (askDefaultBranch); asked
300
309
  // once per repository per run, and only for a pull request that was merged
310
+ // askOrigin how to read the repository a workspace came from (originRepoOf); asked once per
311
+ // workspace per run, and only for a pull request that was merged or closed
312
+ // only a row id: sweep that one job and none of the others (the automatic run goes job
313
+ // by job, to say each result as it is made)
314
+ // listing task rows already read. Given, this call lists and numbers nothing itself, and `only`
315
+ // still picks from it. A job in it is named by its number if the row has one (display_id or
316
+ // legacy_ref) and by its id if it has not. The automatic run reads the workspace once for the
317
+ // whole run and hands each call its job, because it prints no task number and so has none to
318
+ // make. Left out, as the manual sweep leaves it, this call lists and numbers the workspace
319
+ // itself.
320
+ // nameOf how a refusal names the other job that has the pull request, given its row id. Left
321
+ // out, the name is the one that job has in its own workspace's numbering, which lists
322
+ // that workspace's tasks and so can start git when checkouts are linked to it. The
323
+ // automatic run, which says nothing of a refusal and keeps to a time limit, gives the
324
+ // row id itself.
301
325
  //
302
326
  // Returns { ok, action, dry_run, scope, actor, outage, summary, results }. ok is false only on
303
327
  // an outage. Each result is { task_id, ref, title, builder, workspace_root, pull_request,
@@ -305,15 +329,18 @@ function leftAloneDetail(reason) {
305
329
  // unknown or not_asked, and action is what was done: landed, released, would_land,
306
330
  // would_release, none, left_alone, or refused (the pull request is not this job's build: nothing
307
331
  // was written, and detail is the plain line saying why).
308
- function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = false, ask = askGitHub, askBranch = askDefaultBranch } = {}) {
332
+ function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = false, ask = askGitHub, askBranch = askDefaultBranch, askOrigin = originRepoOf, only = null, nameOf = null, listing = null } = {}) {
309
333
  if (!actor) throw new Error('actor required');
310
334
  const scope = workspaceRoot ? 'workspace' : 'everywhere';
311
- // No task list yet means nothing is held.
312
- const inScope = db ? taskStore.listTasks(db, { workspaceRoot: workspaceRoot || null }) : [];
313
- const held = inScope
314
- .filter((row) => row.status === 'review' && taskStore.heldPullRequestOf(row))
315
- .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id).localeCompare(String(b.id)));
316
- const named = taskStore.withTaskDisplayRefs(held, inScope);
335
+ let named;
336
+ if (listing) {
337
+ named = heldJobsAmong(taskStore, listing).filter((row) => !only || row.id === only);
338
+ } else {
339
+ // No task list yet means nothing is held.
340
+ const inScope = db ? taskStore.listTasks(db, { workspaceRoot: workspaceRoot || null }) : [];
341
+ const held = heldJobsAmong(taskStore, inScope).filter((row) => !only || row.id === only);
342
+ named = taskStore.withTaskDisplayRefs(held, inScope);
343
+ }
317
344
 
318
345
  // Each question goes to GitHub once per run, whatever the answer: a failure is not asked again.
319
346
  const answers = new Map();
@@ -347,17 +374,17 @@ function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = fa
347
374
  // A workspace's origin is read once per run, and only if a pull request needs it.
348
375
  const origins = new Map();
349
376
  const originOf = (root) => {
350
- if (!origins.has(root)) origins.set(root, originRepoOf(root));
377
+ if (!origins.has(root)) origins.set(root, askOrigin(root));
351
378
  return origins.get(root);
352
379
  };
353
380
 
354
- // The name a person reads for a job, in its own workspace's numbering.
355
- const refOfJob = (id) => {
381
+ // The name a person reads for a job, in its own workspace's numbering, unless the caller names jobs itself.
382
+ const refOfJob = nameOf || ((id) => {
356
383
  const task = taskStore.getTask(db, id);
357
384
  if (!task) return String(id);
358
385
  const [shown] = taskStore.withTaskDisplayRefs([task], taskStore.listTasks(db, { workspaceRoot: task.workspace_root || null }));
359
386
  return (shown && (shown.display_id || shown.legacy_ref || shown.id)) || String(id);
360
- };
387
+ });
361
388
  // Why this pull request is not this job's to settle, when another job has it.
362
389
  const takenReason = (taken, answer) => (taken.how === 'landed'
363
390
  ? `pull request ${answer.number} in ${answer.repo} already landed with ${refOfJob(taken.id)}`
@@ -586,4 +613,4 @@ function describeHeldSweep(report) {
586
613
  return lines;
587
614
  }
588
615
 
589
- module.exports = { ASK_TIMEOUT_MS, askGitHub, askDefaultBranch, branchNamesJob, originRepoOf, sweepHeldJobs, describeHeldSweep };
616
+ module.exports = { ASK_TIMEOUT_MS, askGitHub, askDefaultBranch, branchNamesJob, heldJobsAmong, originRepoOf, sweepHeldJobs, describeHeldSweep };
@@ -3,7 +3,7 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'idea', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', '99', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
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
- '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',
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', 'sentinel', '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
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
 
@@ -176,7 +176,7 @@ function latestRunReceipt(missionId, cwd) {
176
176
  // mission's own state and no commit made after it. Every tick in that run
177
177
  // must have passed its check and a verdict from a reviewer engine other than
178
178
  // the member and every engine that did the work.
179
- function reviewHold(mission, workerEngine, cwd, pin = {}) {
179
+ function reviewHold(mission, workerEngine, cwd, pin = {}, doneLooksLikes = []) {
180
180
  const receipt = latestRunReceipt(mission.id, cwd);
181
181
  if (!receipt) return 'no run receipt for this mission could be read';
182
182
  const result = receipt.result && typeof receipt.result === 'object' ? receipt.result : {};
@@ -220,6 +220,18 @@ function reviewHold(mission, workerEngine, cwd, pin = {}) {
220
220
  if (!reviewer || self.has(reviewer)) return 'the latest run was reviewed by the same engine that did the work';
221
221
  }
222
222
  if (lastTick.reviewed_git_tree !== head.stdout) return 'the review did not see the code that is checked out now';
223
+ const doneLines = [
224
+ ...(Array.isArray(doneLooksLikes) ? doneLooksLikes : []),
225
+ mission.done_looks_like,
226
+ ].map((line) => String(line || '').trim()).filter(Boolean);
227
+ if (doneLines.length) {
228
+ const { missingDoneLooksLikeClaim } = require('../commands/mission');
229
+ const reviewOutput = String(lastTick.referee_result?.output || '');
230
+ for (const doneLine of doneLines) {
231
+ const missing = missingDoneLooksLikeClaim(doneLine, { cwd, reviewOutput });
232
+ if (missing) return `missing: ${missing}`;
233
+ }
234
+ }
223
235
  pin.tree = head.stdout;
224
236
  return null;
225
237
  }
@@ -459,6 +471,7 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
459
471
  if (missionLane) return held('protected', `this mission sits in a protected area (${missionLane})`);
460
472
  const taskIds = linkedTaskIds(mission);
461
473
  if (!taskIds) return held('protected', 'the mission\'s linked tasks are not a clean list of ids');
474
+ const doneLooksLikes = [];
462
475
  for (const id of taskIds) {
463
476
  let task = null;
464
477
  try {
@@ -475,12 +488,18 @@ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', load
475
488
  if (approvals.some((value) => ['revise', 'revised', 'rejected'].includes(String(value || '').trim().toLowerCase()))) {
476
489
  return held('approval', 'a linked task was turned down; it needs fresh proof before it can go to github');
477
490
  }
491
+ try {
492
+ const explanation = require('./task-explanation').taskExplanation(task);
493
+ if (explanation.sources?.done_looks_like === 'explicit' && explanation.done_looks_like) {
494
+ doneLooksLikes.push(explanation.done_looks_like);
495
+ }
496
+ } catch {}
478
497
  }
479
498
 
480
499
  const unsaved = unsavedHold(cwd);
481
500
  if (unsaved) return held('unsaved', unsaved);
482
501
  const pin = {};
483
- const reviewGap = reviewHold(mission, workerEngine, cwd, pin);
502
+ const reviewGap = reviewHold(mission, workerEngine, cwd, pin, doneLooksLikes);
484
503
  if (reviewGap) return held('review', reviewGap);
485
504
  // Pin the commit the review passed on. Only this commit (or a map-only
486
505
  // merge built on it) is pushed and merged, so a commit made after this
@@ -261,9 +261,9 @@ function progressEvent(member, raw) {
261
261
  // so a line half written before the stream started still arrives once done;
262
262
  // files that appear later are read from the top. scan() is sync, so watch
263
263
  // and poll can both call it without racing.
264
- function afterLastNewline(file) {
264
+ function afterLastNewline(file, onError = () => {}) {
265
265
  let fd;
266
- try { fd = fs.openSync(file, 'r'); } catch { return 0; }
266
+ try { fd = fs.openSync(file, 'r'); } catch (error) { onError(`open ${file}`, error); return 0; }
267
267
  try {
268
268
  const buf = Buffer.alloc(64 * 1024);
269
269
  for (let end = fs.fstatSync(fd).size; end > 0;) {
@@ -274,14 +274,16 @@ function afterLastNewline(file) {
274
274
  end = start;
275
275
  }
276
276
  return 0;
277
- } catch { return 0; } finally { fs.closeSync(fd); }
277
+ } catch (error) { onError(`read ${file}`, error); return 0; } finally { fs.closeSync(fd); }
278
278
  }
279
279
 
280
- function followBoard({ dir = progressDir(), emit, pollMs = 1000, orderByTime = false } = {}) {
280
+ function followBoard({ dir = progressDir(), emit, pollMs = 1000, orderByTime = false, onError = () => {} } = {}) {
281
281
  const offsets = new Map();
282
- const sizeOf = (file) => { try { return fs.statSync(file).size; } catch { return null; } };
283
- const list = () => { try { return fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch { return []; } };
284
- for (const f of list()) offsets.set(f, afterLastNewline(path.join(dir, f)));
282
+ let directorySeen = false;
283
+ const report = (context, error) => { if (error.code !== 'ENOENT' || directorySeen) onError(context, error); };
284
+ const sizeOf = (file) => { try { return fs.statSync(file).size; } catch (error) { report(`stat ${file}`, error); return null; } };
285
+ const list = () => { try { const files = fs.readdirSync(dir); directorySeen = true; return files.filter((f) => f.endsWith('.log')); } catch (error) { report(`list ${dir}`, error); return []; } };
286
+ for (const f of list()) offsets.set(f, afterLastNewline(path.join(dir, f), report));
285
287
 
286
288
  function readNew(f, events) {
287
289
  const file = path.join(dir, f);
@@ -297,7 +299,7 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000, orderByTime = f
297
299
  buf = Buffer.alloc(size - from);
298
300
  buf = buf.subarray(0, fs.readSync(fd, buf, 0, buf.length, from));
299
301
  } finally { fs.closeSync(fd); }
300
- } catch { return; }
302
+ } catch (error) { report(`read ${file}`, error); return; }
301
303
  // Stop at the last newline; a half-written tail waits for the next scan.
302
304
  const end = buf.lastIndexOf(0x0a);
303
305
  if (end < 0) { offsets.set(f, from); return; }
@@ -316,8 +318,8 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000, orderByTime = f
316
318
  if (!watcher) {
317
319
  try {
318
320
  watcher = fs.watch(dir, () => scan());
319
- watcher.on('error', () => { try { watcher.close(); } catch {} watcher = null; });
320
- } catch { watcher = null; }
321
+ watcher.on('error', (error) => { report(`watch ${dir}`, error); try { watcher.close(); } catch (closeError) { report(`close watcher ${dir}`, closeError); } watcher = null; });
322
+ } catch (error) { report(`watch ${dir}`, error); watcher = null; }
321
323
  }
322
324
  const events = [];
323
325
  for (const f of list()) readNew(f, events);