atris 3.76.0 → 3.78.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.
@@ -191,20 +191,23 @@ function shellWord(value) {
191
191
  return `'${s.replace(/'/g, "'\\''")}'`;
192
192
  }
193
193
 
194
- function firstConfiguredEnv(names) {
194
+ // Every reader below takes the environment it reads from and defaults to this process's. A launch is built in
195
+ // this process, so it reads process.env; a caller that wants to know what a launch would carry (launchFor) hands
196
+ // in the environment the launch would be built in, and nothing here looks at this process then.
197
+ function firstConfiguredEnv(names, env = process.env) {
195
198
  for (const name of names) {
196
- const value = String(process.env[name] || '').trim();
199
+ const value = String(env[name] || '').trim();
197
200
  if (value) return value;
198
201
  }
199
202
  return '';
200
203
  }
201
204
 
202
- function resolveRunnerProfileName() {
203
- return String(process.env.ATRIS_RUNNER_PROFILE || '').trim();
205
+ function resolveRunnerProfileName(env = process.env) {
206
+ return String(env.ATRIS_RUNNER_PROFILE || '').trim();
204
207
  }
205
208
 
206
- function resolveRunnerProfile() {
207
- const name = resolveRunnerProfileName();
209
+ function resolveRunnerProfile(env = process.env) {
210
+ const name = resolveRunnerProfileName(env);
208
211
  if (!name) return null;
209
212
  const profile = RUNNER_PROFILES[name];
210
213
  if (!profile) {
@@ -213,8 +216,8 @@ function resolveRunnerProfile() {
213
216
  return profile;
214
217
  }
215
218
 
216
- function runnerProfileValue(key) {
217
- const profile = resolveRunnerProfile();
219
+ function runnerProfileValue(key, env = process.env) {
220
+ const profile = resolveRunnerProfile(env);
218
221
  return profile && profile[key] ? profile[key] : '';
219
222
  }
220
223
 
@@ -233,10 +236,10 @@ function isSpendGatedModel(model) {
233
236
 
234
237
  // `fallback` is the model to run instead. An engine that is not the claude cli
235
238
  // cannot take a claude model id, so it passes '' and rides its own default.
236
- function applySpendGate(model, { silent = false, fallback = SPEND_FALLBACK_MODEL } = {}) {
239
+ function applySpendGate(model, { silent = false, fallback = SPEND_FALLBACK_MODEL, env = process.env } = {}) {
237
240
  const value = String(model || '').trim();
238
241
  if (!value || !isSpendGatedModel(value)) return value;
239
- if (String(process.env.ATRIS_CLAUDE_SPEND_OK || '').trim() === value) return value;
242
+ if (String(env.ATRIS_CLAUDE_SPEND_OK || '').trim() === value) return value;
240
243
  if (!silent && !spendGateWarned.has(`${value}>${fallback}`)) {
241
244
  spendGateWarned.add(`${value}>${fallback}`);
242
245
  const instead = fallback || "that engine's own default model";
@@ -251,37 +254,37 @@ function resetSpendGateWarnings() {
251
254
 
252
255
  // Engines that run only their own vendor's models drop any claude model name
253
256
  // later, so the gate has nothing to stop there and stays quiet.
254
- function runnerDropsClaudeModels() {
255
- const name = resolveRunnerProfileName();
257
+ function runnerDropsClaudeModels(env = process.env) {
258
+ const name = resolveRunnerProfileName(env);
256
259
  const def = name ? RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name] : null;
257
260
  if (def && def.ownModelsOnly) return true;
258
- return Boolean(ownModelsOnlyEngineForBin(resolveClaudeRunnerBin()));
261
+ return Boolean(ownModelsOnlyEngineForBin(resolveClaudeRunnerBin(env)));
259
262
  }
260
263
 
261
264
  // Whether the launch goes through the claude cli, the only engine that can take
262
265
  // the cheap claude fallback by name.
263
- function runnerRidesClaudeCli() {
264
- const name = resolveRunnerProfileName();
266
+ function runnerRidesClaudeCli(env = process.env) {
267
+ const name = resolveRunnerProfileName(env);
265
268
  const canonical = name ? (RUNNER_PROFILE_ALIASES[name] || name) : '';
266
269
  return !canonical || canonical === 'claude' || canonical === 'fable' || canonical === 'haiku';
267
270
  }
268
271
 
269
272
  // The choke point: every unattended Claude launch (mission, autopilot, run,
270
273
  // pulse, fleet) resolves its model here, directly or via buildRunnerCommand.
271
- function resolveClaudeRunnerModel(mission) {
272
- const model = resolveClaudeRunnerModelUngated(mission);
273
- if (!isSpendGatedModel(model) || runnerDropsClaudeModels()) return model;
274
- return applySpendGate(model, { fallback: runnerRidesClaudeCli() ? SPEND_FALLBACK_MODEL : '' });
274
+ function resolveClaudeRunnerModel(mission, { env = process.env, silent = false } = {}) {
275
+ const model = resolveClaudeRunnerModelUngated(mission, env);
276
+ if (!isSpendGatedModel(model) || runnerDropsClaudeModels(env)) return model;
277
+ return applySpendGate(model, { fallback: runnerRidesClaudeCli(env) ? SPEND_FALLBACK_MODEL : '', env, silent });
275
278
  }
276
279
 
277
- function resolveClaudeRunnerModelUngated(mission) {
280
+ function resolveClaudeRunnerModelUngated(mission, env = process.env) {
278
281
  const explicit = mission && mission.model != null ? normalizeClaudeModelName(mission.model) : '';
279
282
  if (explicit) return explicit;
280
- const env = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_RUNNER_MODEL']));
281
- if (env) return env;
282
- const profileModel = normalizeClaudeModelName(runnerProfileValue('model'));
283
+ const fromEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_RUNNER_MODEL'], env));
284
+ if (fromEnv) return fromEnv;
285
+ const profileModel = normalizeClaudeModelName(runnerProfileValue('model', env));
283
286
  if (profileModel) return profileModel;
284
- const legacyEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_CLAUDE_MODEL']));
287
+ const legacyEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_CLAUDE_MODEL'], env));
285
288
  if (legacyEnv) return legacyEnv;
286
289
  return DEFAULT_CLAUDE_RUNNER_MODEL;
287
290
  }
@@ -290,12 +293,12 @@ function resolveClaudeRunnerModelUngated(mission) {
290
293
  // mission model) or the profile's own pin. ATRIS_RUNNER_MODEL is left out on
291
294
  // purpose: it usually names a claude model, which devin or grok would reject.
292
295
  // '' means nothing is pinned, so the engine's CLI picks its own default.
293
- function resolvePinnedRunnerModel(mission) {
296
+ function resolvePinnedRunnerModel(mission, { env = process.env, silent = false } = {}) {
294
297
  const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
295
- const pinned = explicit || runnerProfileValue('model');
298
+ const pinned = explicit || runnerProfileValue('model', env);
296
299
  // Cheap check first: an ungated pin never needs the profile looked up.
297
- if (!isSpendGatedModel(pinned) || runnerDropsClaudeModels()) return pinned;
298
- return applySpendGate(pinned, { fallback: runnerRidesClaudeCli() ? SPEND_FALLBACK_MODEL : '' });
300
+ if (!isSpendGatedModel(pinned) || runnerDropsClaudeModels(env)) return pinned;
301
+ return applySpendGate(pinned, { fallback: runnerRidesClaudeCli(env) ? SPEND_FALLBACK_MODEL : '', env, silent });
299
302
  }
300
303
 
301
304
  // The own-models-only engine a runner bin launches ("codex", "/opt/bin/grok"),
@@ -326,22 +329,22 @@ function runnerModelFor(engineId, model) {
326
329
  return isClaudeFamilyModel(value) ? '' : value;
327
330
  }
328
331
 
329
- function resolveClaudeRunnerBin() {
330
- const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
331
- if (env) return env;
332
- const profileBin = runnerProfileValue('bin');
332
+ function resolveClaudeRunnerBin(env = process.env) {
333
+ const fromEnv = firstConfiguredEnv(['ATRIS_RUNNER_BIN'], env);
334
+ if (fromEnv) return fromEnv;
335
+ const profileBin = runnerProfileValue('bin', env);
333
336
  if (profileBin) return profileBin;
334
- const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_BIN']);
337
+ const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_BIN'], env);
335
338
  if (legacyEnv) return legacyEnv;
336
339
  return DEFAULT_CLAUDE_RUNNER_BIN;
337
340
  }
338
341
 
339
- function resolveClaudeRunnerCommandTemplate() {
340
- const env = firstConfiguredEnv(['ATRIS_RUNNER_COMMAND_TEMPLATE']);
341
- if (env) return env;
342
- const profileTemplate = runnerProfileValue('commandTemplate');
342
+ function resolveClaudeRunnerCommandTemplate(env = process.env) {
343
+ const fromEnv = firstConfiguredEnv(['ATRIS_RUNNER_COMMAND_TEMPLATE'], env);
344
+ if (fromEnv) return fromEnv;
345
+ const profileTemplate = runnerProfileValue('commandTemplate', env);
343
346
  if (profileTemplate) return profileTemplate;
344
- return firstConfiguredEnv(['ATRIS_CLAUDE_COMMAND_TEMPLATE']);
347
+ return firstConfiguredEnv(['ATRIS_CLAUDE_COMMAND_TEMPLATE'], env);
345
348
  }
346
349
 
347
350
  function buildRunnerAvailabilityCommand() {
@@ -453,6 +456,42 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
453
456
  return cmd;
454
457
  }
455
458
 
459
+ // What the launch of one engine would carry, decided from `env` (this process's by default) and without launching
460
+ // anything or saying anything: { engine, bin, model }, or null for an engine nobody knows. `model` is the pin a
461
+ // caller would hand buildRunnerCommand (a roster pick). The model is the --model of the command buildRunnerCommand
462
+ // builds for this engine under ATRIS_RUNNER_PROFILE=<engine>, the way the fleet builds it: the same precedence,
463
+ // the same spend gate, and a claude model dropped by an engine that cannot take one. '' means the launch carries
464
+ // none. A caller that must judge the launch (the builder picker asks the Claude spend guard about it) asks here
465
+ // and never works the model out for itself.
466
+ //
467
+ // Only the fleet's own launch is read. A command template somebody wrote (ATRIS_RUNNER_COMMAND_TEMPLATE, or the
468
+ // older ATRIS_CLAUDE_COMMAND_TEMPLATE) can start anything with anything: a model in it may be a flag, a word of the
469
+ // prompt, another command's, or one the shell works out only when it runs. It is never read for its model. The
470
+ // answer is { engine, bin, model: '', custom: true }, and a caller that has to judge it counts the model as one it
471
+ // cannot tell. A template that is the engine's own is the fleet's launch like any other.
472
+ function launchFor(engineId, { env = process.env, model } = {}) {
473
+ const asked = String(engineId || '').trim().toLowerCase();
474
+ const name = RUNNER_PROFILE_ALIASES[asked] || asked;
475
+ if (!name || !RUNNER_PROFILE_DEFS[name]) return null;
476
+ const scoped = { ...env, ATRIS_RUNNER_PROFILE: name };
477
+ const bin = resolveClaudeRunnerBin(scoped);
478
+ const template = resolveClaudeRunnerCommandTemplate(scoped);
479
+ if (template && template !== runnerProfileValue('commandTemplate', scoped)) {
480
+ return { engine: name, bin, model: '', custom: true };
481
+ }
482
+ const mission = { model };
483
+ const binEngine = ownModelsOnlyEngineForBin(bin);
484
+ const modelFor = (value) => {
485
+ const forProfile = runnerModelFor(name, value);
486
+ return binEngine ? runnerModelFor(binEngine, forProfile) : forProfile;
487
+ };
488
+ const hasModelSlot = !template || /\{(?:model|modelFlag)\}/.test(template);
489
+ const hasPinSlot = Boolean(template) && /\{pinnedModelFlag\}/.test(template);
490
+ const resolved = hasModelSlot ? resolveClaudeRunnerModel(mission, { env: scoped, silent: true }) : '';
491
+ const pinned = hasPinSlot ? resolvePinnedRunnerModel(mission, { env: scoped, silent: true }) : '';
492
+ return { engine: name, bin, model: modelFor(hasModelSlot ? resolved : pinned) };
493
+ }
494
+
456
495
  // Whether the installed codex prints JSONL events and can write its final
457
496
  // answer to a file. Asked once per program; an older codex without the flags
458
497
  // keeps today's plain launch.
@@ -518,4 +557,5 @@ module.exports = {
518
557
  buildRunnerAvailabilityCommand,
519
558
  runnerAvailabilityFailureMessage,
520
559
  buildRunnerCommand,
560
+ launchFor,
521
561
  };
package/lib/task-db.js CHANGED
@@ -645,13 +645,34 @@ const NAMED_CLAIM_SQL = `AND (? IS NULL OR EXISTS (
645
645
  AND (tasks.claimed_at IS NULL OR e.created_at >= tasks.claimed_at)
646
646
  AND e.version = (SELECT MAX(version) FROM task_events WHERE task_id = tasks.id AND event_type = 'claimed')))`;
647
647
 
648
+ // The job as it stands when a claim is made: its title and tags, saved on the claim itself. The builder picker
649
+ // (lib/builder-pick.js) gives a play the lane its job was in when the claim was made, and tagging or renaming a
650
+ // finished job later must not change that. Every way of claiming a job writes it (claimTask, and the claim that
651
+ // starting work on an open or unowned job makes); a claim made before this existed has none and is never rebuilt
652
+ // from the job's history.
653
+ function jobAtClaim(row, metadata) {
654
+ const meta = metadata && typeof metadata === 'object' ? metadata : {};
655
+ return {
656
+ title: String(row.title == null ? '' : row.title),
657
+ tag: row.tag || null,
658
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
659
+ todo_tags: Array.isArray(meta.todo_tags) ? meta.todo_tags : [],
660
+ };
661
+ }
662
+
648
663
  // Atomic claim. Returns { claimed: true, row, event } only if THIS call won the row; event is the claimed event
649
664
  // this claim wrote, the one name for it (see latestClaim). Race-safe via single UPDATE with WHERE status='open'
650
665
  // guard. SQLite serializes writes; busy_timeout absorbs contention. Caller must check `.claimed`.
651
666
  //
652
667
  // The row and its claimed event are written in one transaction. Before, the row was claimed first and the event
653
668
  // followed a git call later, and in that gap a late release naming the claim before could pass for the new one.
654
- function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
669
+ //
670
+ // refuseIf is a check the caller makes on the job as it stands when the claim is made: it is asked with the open job
671
+ // (as getTask shows it) inside the same transaction, with the write lock held, so nothing can change the job between
672
+ // the look and the claim. It answers with a reason to refuse, and the job is then not claimed and nothing is written
673
+ // ({ claimed: false, reason }); an empty answer lets the claim go on. A job that is not open is not asked about, and
674
+ // answers as it always did. A check that throws takes the claim with it: nothing is claimed.
675
+ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid, refuseIf = null }) {
655
676
  if (!id) throw new Error('id required');
656
677
  if (!claimedBy) throw new Error('claimedBy required');
657
678
  const now = Date.now();
@@ -672,6 +693,11 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
672
693
  `);
673
694
  const lease = leaseMinutes == null ? null : JSON.stringify({ until: new Date(now + leaseMinutes * 60000).toISOString(), minutes: leaseMinutes, holder: claimedBy, pid: leasePid });
674
695
  const won = inWriteTransaction(db, () => {
696
+ if (refuseIf) {
697
+ const current = getTask(db, id);
698
+ const why = current && current.status === 'open' ? refuseIf(current) : '';
699
+ if (why) return { refused: String(why) };
700
+ }
675
701
  const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
676
702
  if (result.changes !== 1) return null;
677
703
  let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
@@ -693,10 +719,16 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
693
719
  workspaceRoot: row.workspace_root,
694
720
  actor: claimedBy,
695
721
  eventType: 'claimed',
696
- payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
722
+ payload: {
723
+ claimed_by: claimedBy,
724
+ ...(startCommit ? { start_commit: startCommit } : {}),
725
+ // Written in this transaction, so a claim never exists without it.
726
+ job_at_claim: jobAtClaim(row, claimedMetadata),
727
+ },
697
728
  });
698
729
  return { row, event };
699
730
  });
731
+ if (won && won.refused) return { claimed: false, reason: won.refused };
700
732
  if (won) {
701
733
  const enriched = { ...won.row, metadata: won.row.metadata ? safeJSON(won.row.metadata) : null };
702
734
  // The work log is a note for people, written once the claim is saved. A note that cannot be written must not
@@ -2300,7 +2332,7 @@ function stageTaskSteps(db, {
2300
2332
  workspaceRoot: updated.workspace_root,
2301
2333
  actor: claimedWorker,
2302
2334
  eventType: 'claimed',
2303
- payload: { claimed_by: claimedWorker },
2335
+ payload: { claimed_by: claimedWorker, job_at_claim: jobAtClaim(updated, updated.metadata) },
2304
2336
  });
2305
2337
  }
2306
2338
  const event = appendTaskEvent(db, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.76.0",
3
+ "version": "3.78.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",