atris 3.74.12 → 3.74.14
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/commands/member.js +20 -4
- package/commands/mission.js +71 -2
- package/lib/gm-bet.js +308 -30
- package/lib/gm-card.js +13 -3
- package/lib/zen-apply.js +12 -4
- package/lib/zen.js +1 -0
- package/package.json +1 -1
package/commands/member.js
CHANGED
|
@@ -785,6 +785,7 @@ const MEMBER_RUN_START_BOOLEAN_FLAGS = [
|
|
|
785
785
|
'--json',
|
|
786
786
|
'--no-verify',
|
|
787
787
|
'--referee',
|
|
788
|
+
'--referee-if-check-edited',
|
|
788
789
|
'--no-referee',
|
|
789
790
|
'--worktree',
|
|
790
791
|
'--shared-checkout',
|
|
@@ -1064,9 +1065,21 @@ function startMemberRunMission(name, missionText, args = []) {
|
|
|
1064
1065
|
}
|
|
1065
1066
|
|
|
1066
1067
|
// An ignited run asks a second engine to review the work by default, so every
|
|
1067
|
-
// run ends with a verdict.
|
|
1068
|
-
|
|
1069
|
-
|
|
1068
|
+
// run ends with a verdict. A real test command is already that verdict: the
|
|
1069
|
+
// second engine only reran the same commands and cost about a minute a run
|
|
1070
|
+
// (member lab 2026-10-24), so it stays for runs whose check is not a test
|
|
1071
|
+
// runner (whitespace, syntax, or file-exists checks). --no-referee skips it; an explicit --referee is kept.
|
|
1072
|
+
function memberRunHasRealCheck(verifier) {
|
|
1073
|
+
const command = String(verifier || '').trim();
|
|
1074
|
+
// A plain test runner only: chaining (|| true, ; , &&, pipes, subshells) or a
|
|
1075
|
+
// pass-with-no-tests switch can make any run "pass", so those keep the review.
|
|
1076
|
+
if (/\|\||&&|[;|`\r\n]|\$\(|pass-?with-?no-?tests|--no-tests|--exit-zero/i.test(command)) return false;
|
|
1077
|
+
return /^((npm|pnpm|yarn)\s+(run\s+)?test\b|node\s+--test\b|npx\s+(jest|vitest|mocha)\b|pytest\b|python3?\s+-m\s+pytest\b|go\s+test\b|cargo\s+test\b)/i.test(command);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function memberRunWantsReferee(runArgs, ignite, verifier = '') {
|
|
1081
|
+
return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee')
|
|
1082
|
+
&& !memberRunHasRealCheck(verifier);
|
|
1070
1083
|
}
|
|
1071
1084
|
|
|
1072
1085
|
function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
|
|
@@ -1393,7 +1406,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
|
|
|
1393
1406
|
['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
|
|
1394
1407
|
['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
|
|
1395
1408
|
if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
|
|
1396
|
-
if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
|
|
1409
|
+
if (memberRunWantsReferee(runArgs, ignite, mission.verifier)) runArgs.push('--referee');
|
|
1410
|
+
// Skipped because a real test is the check: review anyway if the worker
|
|
1411
|
+
// edits what that test command runs.
|
|
1412
|
+
else if (ignite && memberRunHasRealCheck(mission.verifier) && !hasFlag(runArgs, '--no-referee')) runArgs.push('--referee-if-check-edited');
|
|
1397
1413
|
const budgetSeconds = memberRunBudgetSeconds(args);
|
|
1398
1414
|
if (!readFlag(runArgs, '--max-ticks', '')) {
|
|
1399
1415
|
runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
|
package/commands/mission.js
CHANGED
|
@@ -788,6 +788,7 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
|
|
|
788
788
|
'--no-claude',
|
|
789
789
|
'--no-verify',
|
|
790
790
|
'--referee',
|
|
791
|
+
'--referee-if-check-edited',
|
|
791
792
|
'--no-referee',
|
|
792
793
|
'--complete-on-pass',
|
|
793
794
|
'--no-drain',
|
|
@@ -6950,6 +6951,39 @@ function missionProtectedTags(mission, root = process.cwd()) {
|
|
|
6950
6951
|
return tags;
|
|
6951
6952
|
}
|
|
6952
6953
|
|
|
6954
|
+
// True when the tick changed what its check command runs: package.json (which
|
|
6955
|
+
// defines npm test) or a file the command names. A passing test the worker
|
|
6956
|
+
// rewrote is not evidence on its own.
|
|
6957
|
+
function tickTouchedCheckDefinition(verifier, before, root = process.cwd()) {
|
|
6958
|
+
const run = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
6959
|
+
const head = String(before?.head || '').trim();
|
|
6960
|
+
const changed = [
|
|
6961
|
+
// No starting commit to compare against: every tracked file counts.
|
|
6962
|
+
...String(run(head ? ['diff', '--name-only', head] : ['ls-files']).stdout || '').split('\n'),
|
|
6963
|
+
...String(run(['diff', '--name-only', 'HEAD']).stdout || '').split('\n'),
|
|
6964
|
+
...String(run(['ls-files', '--others', '--exclude-standard']).stdout || '').split('\n'),
|
|
6965
|
+
].map((file) => file.trim()).filter(Boolean);
|
|
6966
|
+
// npm test runs whatever package.json says, so its paths and folders count too.
|
|
6967
|
+
let checkText = String(verifier || '');
|
|
6968
|
+
if (/^(npm|pnpm|yarn)\s/i.test(checkText.trim())) {
|
|
6969
|
+
try { checkText += ` ${JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))?.scripts?.test || ''}`; } catch {}
|
|
6970
|
+
}
|
|
6971
|
+
// Runners that find tests by name (node --test, jest, pytest, go test) never
|
|
6972
|
+
// list them, so any changed file shaped like a test counts as well.
|
|
6973
|
+
const testShaped = /(^|\/)(tests?|__tests__|specs?|checks?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$|(^|\/)conftest\.py$/i;
|
|
6974
|
+
return changed.some((file) => path.basename(file) === 'package.json'
|
|
6975
|
+
|| testShaped.test(file)
|
|
6976
|
+
|| checkText.includes(file)
|
|
6977
|
+
|| (path.dirname(file) !== '.' && checkText.includes(`${path.dirname(file)}/`)));
|
|
6978
|
+
}
|
|
6979
|
+
|
|
6980
|
+
// The worker's `learned:` line from its receipt, so the lesson reaches the
|
|
6981
|
+
// member log (and the next worker's preloaded memory) without a hand edit.
|
|
6982
|
+
function receiptLearnedLine(text) {
|
|
6983
|
+
const match = String(text || '').match(/^[\s*-]*learned:\s*(.+)$/im);
|
|
6984
|
+
return match ? match[1].trim().slice(0, 240) : undefined;
|
|
6985
|
+
}
|
|
6986
|
+
|
|
6953
6987
|
function inspectMissionTickProtectedDiff(mission, before, root = process.cwd()) {
|
|
6954
6988
|
const after = gitWorktreeSnapshot(root);
|
|
6955
6989
|
if (before?.reason === 'not-git-worktree' && after?.reason === 'not-git-worktree') {
|
|
@@ -9153,6 +9187,31 @@ function readBoundedContextFile(filePath, maxChars = 1600) {
|
|
|
9153
9187
|
}
|
|
9154
9188
|
}
|
|
9155
9189
|
|
|
9190
|
+
// Code members never call the account-wide tool servers (mail, calendar,
|
|
9191
|
+
// docs), yet loading them cost about 5s per worker start in the member lab.
|
|
9192
|
+
// They keep the project's own .mcp.json; ATRIS_MEMBER_KEEP_MCP=1 keeps all.
|
|
9193
|
+
const CODE_MEMBERS = new Set(['executor', 'builder', 'codex-executor', 'auto-improver', 'navigator', 'validator', 'architect']);
|
|
9194
|
+
|
|
9195
|
+
function claudeRunnerMcpArgs(mission, cwd = process.cwd(), env = process.env) {
|
|
9196
|
+
if (env.ATRIS_MEMBER_KEEP_MCP === '1') return [];
|
|
9197
|
+
if (!CODE_MEMBERS.has(String(mission?.owner || '').trim().toLowerCase())) return [];
|
|
9198
|
+
const args = ['--strict-mcp-config'];
|
|
9199
|
+
const projectConfig = path.join(cwd, '.mcp.json');
|
|
9200
|
+
if (fs.existsSync(projectConfig)) args.push('--mcp-config', projectConfig);
|
|
9201
|
+
// Servers added for this folder with Claude's local scope live in
|
|
9202
|
+
// ~/.claude.json under the folder's path; keep those too.
|
|
9203
|
+
try {
|
|
9204
|
+
const home = env.HOME || os.homedir();
|
|
9205
|
+
const projects = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))?.projects || {};
|
|
9206
|
+
// A side checkout's servers are usually registered under the main checkout.
|
|
9207
|
+
const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' });
|
|
9208
|
+
const mainRoot = common.status === 0 ? path.dirname(path.resolve(cwd, String(common.stdout || '').trim())) : '';
|
|
9209
|
+
const local = { ...(mainRoot ? projects[mainRoot]?.mcpServers : null), ...projects[path.resolve(cwd)]?.mcpServers };
|
|
9210
|
+
if (Object.keys(local).length) args.push('--mcp-config', JSON.stringify({ mcpServers: local }));
|
|
9211
|
+
} catch {}
|
|
9212
|
+
return args;
|
|
9213
|
+
}
|
|
9214
|
+
|
|
9156
9215
|
function buildPreloadedMemberContextLines(mission, root = process.cwd()) {
|
|
9157
9216
|
const owner = String(mission?.owner || '').trim();
|
|
9158
9217
|
const baseRoot = mission?.worktree_root || mission?.worktree?.path || root;
|
|
@@ -9282,7 +9341,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
9282
9341
|
``,
|
|
9283
9342
|
`## Your task`,
|
|
9284
9343
|
`Do ONE increment of work toward the stop condition. ONE. No more.`,
|
|
9285
|
-
`- ${memberIdentityLine(mission.owner)}
|
|
9344
|
+
`- ${memberIdentityLine(mission.owner)} Your member files are preloaded above; open atris/team/${mission.owner}/MEMBER.md only if that copy is marked truncated. Work in that identity, inside its scope and stop rules. Atris writes your member log from your receipt, so end the receipt with one \`learned: <one line>\` line instead of editing the log.`,
|
|
9286
9345
|
`- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
|
|
9287
9346
|
`- Pick the smallest concrete action that moves the mission forward.`,
|
|
9288
9347
|
`- Before acting, state your single next move in one sentence.`,
|
|
@@ -9290,7 +9349,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
9290
9349
|
`- The tick is not done until the verify step passes. If a frozen verifier exists, the harness runs it, but that does not replace the real-surface proof below. If no frozen verifier exists, finish by actually running the surface you changed on this computer and paste the real command output into your receipt.`,
|
|
9291
9350
|
`- After that one verify step, STOP. Never start a second slice in one tick.`,
|
|
9292
9351
|
`- Write the tick summary in operator language: what changed and what it buys or costs in plain words, with no flags, task ids, or code identifiers.`,
|
|
9293
|
-
`- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt
|
|
9352
|
+
`- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt so the team compounds exponentially.`,
|
|
9294
9353
|
``,
|
|
9295
9354
|
`## No claimable work? Work the backstop ladder`,
|
|
9296
9355
|
`If the mission and task state offer no claimable work, inspect these rungs in order. Pick the FIRST rung with real fuel and do ONE bounded slice:`,
|
|
@@ -9686,6 +9745,7 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9686
9745
|
result: finalText,
|
|
9687
9746
|
summary: usefulClaudeReceiptSummary(finalText || errStr, ok ? 'no-text' : 'error'),
|
|
9688
9747
|
receipt_text: cappedClaudeReceiptText(finalText || errStr),
|
|
9748
|
+
learned: receiptLearnedLine(finalText),
|
|
9689
9749
|
duration_total_ms: Date.now() - startedAt,
|
|
9690
9750
|
num_turns: null,
|
|
9691
9751
|
stop_reason: null,
|
|
@@ -9775,6 +9835,7 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9775
9835
|
];
|
|
9776
9836
|
if (model) args.push('--model', model);
|
|
9777
9837
|
if (mission.roster_effort) args.push('--effort', mission.roster_effort);
|
|
9838
|
+
args.push(...claudeRunnerMcpArgs(mission, cwd));
|
|
9778
9839
|
if (sessionMode === 'set') args.push('--session-id', sessionId);
|
|
9779
9840
|
else if (sessionMode === 'resume') args.push('--resume', sessionId);
|
|
9780
9841
|
|
|
@@ -9881,6 +9942,7 @@ function spawnClaudeTick(mission, opts) {
|
|
|
9881
9942
|
result: finalText,
|
|
9882
9943
|
summary: usefulClaudeReceiptSummary(finalText, ok ? 'no-text' : 'error'),
|
|
9883
9944
|
receipt_text: cappedClaudeReceiptText(finalText),
|
|
9945
|
+
learned: receiptLearnedLine(finalText),
|
|
9884
9946
|
api_equivalent_estimate: costEstimate,
|
|
9885
9947
|
duration_api_ms: durationApiMs,
|
|
9886
9948
|
duration_total_ms: Date.now() - startedAt,
|
|
@@ -10124,6 +10186,7 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10124
10186
|
const skipClaude = hasFlag(args, '--no-claude');
|
|
10125
10187
|
const verifyEach = !hasFlag(args, '--no-verify');
|
|
10126
10188
|
const refereeEach = hasFlag(args, '--referee') && !hasFlag(args, '--no-referee');
|
|
10189
|
+
const refereeIfCheckEdited = hasFlag(args, '--referee-if-check-edited') && !hasFlag(args, '--no-referee');
|
|
10127
10190
|
const refereeEngine = readFlag(args, '--referee-engine', '');
|
|
10128
10191
|
const completeOnPass = hasFlag(args, '--complete-on-pass');
|
|
10129
10192
|
const skipDrain = hasFlag(args, '--no-drain');
|
|
@@ -10161,6 +10224,7 @@ async function parseAndValidateMissionRunPhase(args) {
|
|
|
10161
10224
|
skipClaude,
|
|
10162
10225
|
verifyEach,
|
|
10163
10226
|
refereeEach,
|
|
10227
|
+
refereeIfCheckEdited,
|
|
10164
10228
|
refereeEngine,
|
|
10165
10229
|
completeOnPass,
|
|
10166
10230
|
skipDrain,
|
|
@@ -10995,6 +11059,7 @@ async function executeMissionRunTicksPhase(context) {
|
|
|
10995
11059
|
verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
|
|
10996
11060
|
summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
|
|
10997
11061
|
next_push: result.next_push ? String(result.next_push).slice(0, 180) : undefined,
|
|
11062
|
+
learned: result?.claude?.learned || receiptLearnedLine(result?.claude?.result || result?.claude?.receipt_text),
|
|
10998
11063
|
feedback: verifierResult && !verifierResult.passed ? String(mission.last_check_feedback || '').slice(0, 240) : undefined,
|
|
10999
11064
|
receipt: receiptPath,
|
|
11000
11065
|
}, cwd);
|
|
@@ -11192,6 +11257,7 @@ async function verifyMissionRunTickPhase(context) {
|
|
|
11192
11257
|
&& (
|
|
11193
11258
|
!currentTick.frozen.verifier
|
|
11194
11259
|
|| context.refereeEach
|
|
11260
|
+
|| (context.refereeIfCheckEdited && tickTouchedCheckDefinition(currentTick.frozen.verifier, currentTick.tickWorktreeBefore, cwd))
|
|
11195
11261
|
|| (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
|
|
11196
11262
|
);
|
|
11197
11263
|
if (shouldRunReferee) {
|
|
@@ -12860,6 +12926,9 @@ function missionCommand(args) {
|
|
|
12860
12926
|
}
|
|
12861
12927
|
|
|
12862
12928
|
module.exports = {
|
|
12929
|
+
receiptLearnedLine,
|
|
12930
|
+
tickTouchedCheckDefinition,
|
|
12931
|
+
claudeRunnerMcpArgs,
|
|
12863
12932
|
crystallizeMemberSkillLesson,
|
|
12864
12933
|
listSiblingWorkspaceMissionHints,
|
|
12865
12934
|
missionProtectedLaneHold,
|
package/lib/gm-bet.js
CHANGED
|
@@ -372,7 +372,11 @@ const SAY_READ_MS = 15000;
|
|
|
372
372
|
const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
|
|
373
373
|
// on the weekly number card, what an instruction wants done with the number
|
|
374
374
|
const NUMBER_INTENTS = new Set(['stop', 'change', 'later', 'other']);
|
|
375
|
+
// only for a model that answered and could not make sense of the words
|
|
375
376
|
const SAY_FALLBACK = 'say that another way, or tap a button.';
|
|
377
|
+
// no model answered the words: never put on them
|
|
378
|
+
const SAY_NO_MODEL = 'the orb needs a model signed in on this computer to read that, so nothing was saved.';
|
|
379
|
+
const SAY_NOT_HEARD = 'the model did not answer this time, so nothing was saved. try again in a moment.';
|
|
376
380
|
// the last turns of a card's conversation the reader sees
|
|
377
381
|
const THREAD_TURNS = 6;
|
|
378
382
|
|
|
@@ -391,7 +395,10 @@ function readerModel(workspaceRoot) {
|
|
|
391
395
|
// One quick call: what kind of message the owner typed on the card on
|
|
392
396
|
// screen, and what to say back. The card, the conversation on it so far, and
|
|
393
397
|
// a short view of the project go in.
|
|
394
|
-
|
|
398
|
+
// A read no model answered says so (no_model, retry from think); a model
|
|
399
|
+
// that answered with a read of no use says answered, so only that one asks
|
|
400
|
+
// the owner to say it another way.
|
|
401
|
+
async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {}, timeoutMs = SAY_READ_MS } = {}) {
|
|
395
402
|
const context = clip((deps.readContext || readProjectContext)(workspaceRoot), 1500);
|
|
396
403
|
const goal = bundle && bundle.sources && bundle.sources.goal;
|
|
397
404
|
const prompt = [
|
|
@@ -424,11 +431,12 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
|
|
|
424
431
|
context || '(no readme or goals found)',
|
|
425
432
|
].filter(l => l !== '').join('\n');
|
|
426
433
|
const model = (deps.readerModel || readerModel)(workspaceRoot);
|
|
427
|
-
const
|
|
428
|
-
|
|
434
|
+
const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
|
|
435
|
+
const said = await run(prompt, { cwd: workspaceRoot, timeoutMs, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
|
|
436
|
+
if (!said.ok) return { ok: false, reason: said.reason, ...(said.no_model ? { no_model: said.no_model, retry: Boolean(said.retry) } : {}) };
|
|
429
437
|
const parsed = parseBet(said.text);
|
|
430
438
|
const v = parsed.bet;
|
|
431
|
-
if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
|
|
439
|
+
if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for', answered: true };
|
|
432
440
|
const suggestions = Array.isArray(v.suggestions) ? v.suggestions.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : [];
|
|
433
441
|
const intent = card.kind === 'number' && NUMBER_INTENTS.has(v.intent) ? v.intent : null;
|
|
434
442
|
return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(intent ? { intent } : {}), ...(suggestions.length ? { suggestions } : {}) };
|
|
@@ -452,7 +460,8 @@ async function generateScoreboardChoices({ workspaceRoot, bundle = null, deps =
|
|
|
452
460
|
'',
|
|
453
461
|
bundleText(b),
|
|
454
462
|
].join('\n');
|
|
455
|
-
const
|
|
463
|
+
const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
|
|
464
|
+
const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
|
|
456
465
|
if (!said.ok) return { ok: false, reason: said.reason };
|
|
457
466
|
const parsed = parseBet(said.text);
|
|
458
467
|
const list = parsed.bet && Array.isArray(parsed.bet.choices) ? parsed.bet.choices : [];
|
|
@@ -470,7 +479,7 @@ async function generateQuestion({ workspaceRoot, answers = [], avoid = [], bundl
|
|
|
470
479
|
const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
|
|
471
480
|
const context = (deps.readContext || readProjectContext)(workspaceRoot);
|
|
472
481
|
const source = `${context}\n${bundleText(b)}`;
|
|
473
|
-
const run = deps.runModel ||
|
|
482
|
+
const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
|
|
474
483
|
const ask = async (prompt) => {
|
|
475
484
|
const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
|
|
476
485
|
if (!said.ok) return { failed: said.reason };
|
|
@@ -1523,10 +1532,14 @@ function quietDir() {
|
|
|
1523
1532
|
// Writers running now, by process group, so the server can end them when it
|
|
1524
1533
|
// stops; otherwise a stopped server leaves them running on their own.
|
|
1525
1534
|
const LIVE_WRITERS = new Map();
|
|
1535
|
+
// how many times the server stopped its writers: a call under way when it
|
|
1536
|
+
// did starts no other model after
|
|
1537
|
+
let WRITER_STOPS = 0;
|
|
1526
1538
|
|
|
1527
1539
|
// SIGTERM now, then SIGKILL for the same groups after the grace, so a child
|
|
1528
1540
|
// that ignores SIGTERM does not outlive the server either.
|
|
1529
1541
|
function stopWriters() {
|
|
1542
|
+
WRITER_STOPS += 1;
|
|
1530
1543
|
let n = 0;
|
|
1531
1544
|
const groups = [...LIVE_WRITERS];
|
|
1532
1545
|
for (const [pid, kill] of groups) {
|
|
@@ -1544,6 +1557,9 @@ function stopWriters() {
|
|
|
1544
1557
|
return n;
|
|
1545
1558
|
}
|
|
1546
1559
|
|
|
1560
|
+
// A start that failed because the program is not on this computer.
|
|
1561
|
+
const notHere = err => (err && err.code === 'ENOENT' ? { missing: true } : {});
|
|
1562
|
+
|
|
1547
1563
|
// A signal (an AbortSignal) stops the call early: the whole process group
|
|
1548
1564
|
// gets SIGTERM, then SIGKILL if it is still there after STOP_GRACE_MS, and
|
|
1549
1565
|
// the call resolves once the process is gone, so nothing it started is left.
|
|
@@ -1561,7 +1577,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
1561
1577
|
try {
|
|
1562
1578
|
child = spawnFn(command.bin, args, { cwd, env: modelEnv(env), detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1563
1579
|
} catch (err) {
|
|
1564
|
-
resolve({ ok: false, reason: `could not start the model: ${err.message}
|
|
1580
|
+
resolve({ ok: false, reason: `could not start the model: ${err.message}`, ...notHere(err) });
|
|
1565
1581
|
return;
|
|
1566
1582
|
}
|
|
1567
1583
|
if (child && child.pid) LIVE_WRITERS.set(child.pid, kill);
|
|
@@ -1571,6 +1587,8 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
1571
1587
|
let text = '';
|
|
1572
1588
|
let result = null;
|
|
1573
1589
|
let thinking = 0;
|
|
1590
|
+
// the model began to answer: the api sent its first event back
|
|
1591
|
+
let heard = false;
|
|
1574
1592
|
let timedOut = false;
|
|
1575
1593
|
let stopped = false;
|
|
1576
1594
|
let hardKill = null;
|
|
@@ -1610,6 +1628,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
1610
1628
|
} catch {
|
|
1611
1629
|
return;
|
|
1612
1630
|
}
|
|
1631
|
+
if (ev.type === 'stream_event' || ev.type === 'assistant' || ev.type === 'result') heard = true;
|
|
1613
1632
|
if (ev.type === 'system' && ev.subtype === 'thinking_tokens' && Number.isFinite(ev.estimated_tokens)) {
|
|
1614
1633
|
thinking = ev.estimated_tokens;
|
|
1615
1634
|
onProgress({ thinking, chars: text.length, text });
|
|
@@ -1641,11 +1660,12 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
1641
1660
|
if (!stopped) LIVE_WRITERS.delete(child.pid);
|
|
1642
1661
|
resolve(out);
|
|
1643
1662
|
};
|
|
1644
|
-
child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}
|
|
1663
|
+
child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}`, ...notHere(err) }));
|
|
1645
1664
|
child.on('close', (code) => {
|
|
1646
1665
|
if (buf) line(buf);
|
|
1647
|
-
|
|
1648
|
-
if (
|
|
1666
|
+
const began = heard ? { heard: true } : {};
|
|
1667
|
+
if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true, ...began });
|
|
1668
|
+
if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true, ...began });
|
|
1649
1669
|
if (result && result.is_error) return finish({ ok: false, reason: `the model said: ${clip(result.result || result.subtype, 160)}` });
|
|
1650
1670
|
const answer = result && typeof result.result === 'string' ? result.result : text;
|
|
1651
1671
|
if (code !== 0 && !answer) return finish({ ok: false, reason: `the model exited with code ${code}${stderr.trim() ? `: ${clip(stderr.trim().split('\n').pop(), 160)}` : ''}` });
|
|
@@ -1655,6 +1675,196 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
1655
1675
|
});
|
|
1656
1676
|
}
|
|
1657
1677
|
|
|
1678
|
+
// ---- who thinks: claude first, then the next model signed in here ---------------------
|
|
1679
|
+
|
|
1680
|
+
// The orb thinks on claude, the way it always has. When claude is not on
|
|
1681
|
+
// this computer, fails, or never starts to answer, the next model the
|
|
1682
|
+
// engine registry says is here answers instead, in the registry's own
|
|
1683
|
+
// fallback order, each on its own default model and launched the way
|
|
1684
|
+
// `atris engine ask` launches it (read-only). Hosted engines are left out:
|
|
1685
|
+
// they spend the owner's atris tokens, and the orb drafts in the background.
|
|
1686
|
+
//
|
|
1687
|
+
// What is known lives in memory, one entry per workspace: the registry's
|
|
1688
|
+
// list, read the way every router reads it (the first read saves it, later
|
|
1689
|
+
// reads take about a millisecond; an unsaved one probes every tool, about
|
|
1690
|
+
// half a second on a busy Mac) and kept for ten minutes (THINKER_LIST_MS),
|
|
1691
|
+
// and the models that just failed, which sit out for five minutes
|
|
1692
|
+
// (THINKER_BENCH_MS). A tap on the card that says no model answered forgets
|
|
1693
|
+
// both (forgetThinkers), so a model signed in a moment ago works on the very
|
|
1694
|
+
// next tap.
|
|
1695
|
+
const THINKER_LIST_MS = 10 * 60 * 1000;
|
|
1696
|
+
const THINKER_BENCH_MS = 5 * 60 * 1000;
|
|
1697
|
+
// claude that sent nothing back from the api for this long is not answering
|
|
1698
|
+
const UNHEARD_MS = 15000;
|
|
1699
|
+
const THINKERS = new Map();
|
|
1700
|
+
|
|
1701
|
+
function thinkerState(root) {
|
|
1702
|
+
const key = path.resolve(String(root || '.'));
|
|
1703
|
+
if (!THINKERS.has(key)) THINKERS.set(key, { list: null, listAt: 0, bench: new Map(), answered: new Map(), benches: 0, fresh: false });
|
|
1704
|
+
return THINKERS.get(key);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// The next list read looks for every tool itself. The count of benches
|
|
1708
|
+
// only grows, so a drafting under way still knows whether one sat out.
|
|
1709
|
+
function forgetThinkers(root) {
|
|
1710
|
+
const st = thinkerState(root);
|
|
1711
|
+
st.list = null;
|
|
1712
|
+
st.fresh = true;
|
|
1713
|
+
st.bench.clear();
|
|
1714
|
+
st.answered.clear();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// The models that can think here, in order: the one on the claude program
|
|
1718
|
+
// first (it runs the orb's own command), then every other installed,
|
|
1719
|
+
// non-hosted engine by the registry's fallback order, one per program.
|
|
1720
|
+
function thinkerLineup(root) {
|
|
1721
|
+
const st = thinkerState(root);
|
|
1722
|
+
if (st.list && Date.now() - st.listAt < THINKER_LIST_MS) return st.list;
|
|
1723
|
+
let engines = [];
|
|
1724
|
+
try {
|
|
1725
|
+
const reg = require('./engine-registry');
|
|
1726
|
+
const known = reg.readEngineRegistry(root).engines.filter(e => !reg.engineHosted(e));
|
|
1727
|
+
engines = known.filter(e => e.installed === true);
|
|
1728
|
+
// A saved "not here" can be older than the tool on this computer now (a
|
|
1729
|
+
// saved credit wall is never looked at again, a missing tool for a
|
|
1730
|
+
// minute), so the orb looks for itself: for its own model on every read,
|
|
1731
|
+
// and for every tool after a tap on the card that says no model answered.
|
|
1732
|
+
const bins = new Set(engines.map(e => e.bin));
|
|
1733
|
+
for (const e of known.filter(x => st.fresh || x.id === 'claude')) {
|
|
1734
|
+
if (bins.has(e.bin)) continue;
|
|
1735
|
+
bins.add(e.bin);
|
|
1736
|
+
if (reg.binInstalled(e.bin)) engines.push(e);
|
|
1737
|
+
}
|
|
1738
|
+
} catch {
|
|
1739
|
+
engines = [];
|
|
1740
|
+
}
|
|
1741
|
+
const lead = e => (e.bin === 'claude' ? 0 : 1);
|
|
1742
|
+
engines.sort((a, b) => lead(a) - lead(b) || Number(a.fallback_order) - Number(b.fallback_order) || String(a.id).localeCompare(String(b.id)));
|
|
1743
|
+
const once = new Set();
|
|
1744
|
+
st.list = engines.filter(e => !once.has(e.bin) && once.add(e.bin)).map(e => ({ id: e.id, lead: e.bin === 'claude' }));
|
|
1745
|
+
st.listAt = Date.now();
|
|
1746
|
+
st.fresh = false;
|
|
1747
|
+
return st.list;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function benched(st, id) {
|
|
1751
|
+
const b = st.bench.get(id);
|
|
1752
|
+
if (!b) return false;
|
|
1753
|
+
if (Date.now() < b.until) return true;
|
|
1754
|
+
st.bench.delete(id);
|
|
1755
|
+
return false;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function benchThinker(st, id, reason, missing = false) {
|
|
1759
|
+
st.bench.set(id, { until: Date.now() + THINKER_BENCH_MS, reason: clip(String(reason || ''), 160), missing });
|
|
1760
|
+
st.benches += 1;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// 'ready': a model here can take the next call. 'none': no model is on this
|
|
1764
|
+
// computer. 'failed': every one here failed or never answered just now.
|
|
1765
|
+
function thinkerVerdict(root) {
|
|
1766
|
+
const st = thinkerState(root);
|
|
1767
|
+
const list = thinkerLineup(root);
|
|
1768
|
+
if (list.some(t => !benched(st, t.id))) return 'ready';
|
|
1769
|
+
return list.every(t => st.bench.get(t.id) && st.bench.get(t.id).missing) ? 'none' : 'failed';
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// A fallback model's child joins the writers the server ends when it stops.
|
|
1773
|
+
function trackedSpawn(bin, args, opts) {
|
|
1774
|
+
const child = require('child_process').spawn(bin, args, opts);
|
|
1775
|
+
if (child && child.pid) {
|
|
1776
|
+
const kill = process.kill.bind(process);
|
|
1777
|
+
LIVE_WRITERS.set(child.pid, kill);
|
|
1778
|
+
child.once('close', () => { if (LIVE_WRITERS.get(child.pid) === kill) LIVE_WRITERS.delete(child.pid); });
|
|
1779
|
+
}
|
|
1780
|
+
return child;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// One answer from a model other than claude, through the read-only command
|
|
1784
|
+
// `atris engine ask` builds for it, in the same shape runModel answers.
|
|
1785
|
+
function askThinker(id, prompt, { timeoutMs = MODEL_TIMEOUT_MS, onProgress = () => {}, signal = null } = {}) {
|
|
1786
|
+
const ask = require('./engine-ask');
|
|
1787
|
+
let invocation;
|
|
1788
|
+
try {
|
|
1789
|
+
invocation = ask.buildReadOnlyEngineInvocation(id, prompt, '', { timeoutMs, effort: IDEA_EFFORT });
|
|
1790
|
+
} catch (err) {
|
|
1791
|
+
return Promise.resolve({ ok: false, reason: `could not start the model: ${err.message}` });
|
|
1792
|
+
}
|
|
1793
|
+
let text = '';
|
|
1794
|
+
// any word on stdout: the model began to answer
|
|
1795
|
+
let heard = false;
|
|
1796
|
+
return ask.runAskProcess(invocation, {
|
|
1797
|
+
cwd: invocation.cwd || quietDir(),
|
|
1798
|
+
timeoutMs,
|
|
1799
|
+
signal,
|
|
1800
|
+
env: modelEnv(process.env),
|
|
1801
|
+
spawnProcess: trackedSpawn,
|
|
1802
|
+
onOutputChunk: (chunk, stream) => {
|
|
1803
|
+
if (stream !== 'stdout' || text.length > OUTPUT_MAX) return;
|
|
1804
|
+
heard = true;
|
|
1805
|
+
text += chunk.toString('utf8');
|
|
1806
|
+
onProgress({ thinking: 0, chars: text.length, text });
|
|
1807
|
+
},
|
|
1808
|
+
}).then((r) => {
|
|
1809
|
+
const began = heard ? { heard: true } : {};
|
|
1810
|
+
if (r.cancelled) return { ok: false, reason: STOPPED_REASON, stopped: true, ...began };
|
|
1811
|
+
if (r.timed_out) return { ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true, ...began };
|
|
1812
|
+
const tail = String(r.stderr || r.stdout || '').trim().split('\n').pop();
|
|
1813
|
+
if (r.reason === 'spawn_error') return { ok: false, reason: `could not start the model: ${clip(tail, 160)}`, ...(/ENOENT/.test(r.stderr || '') ? { missing: true } : {}) };
|
|
1814
|
+
// a failed exit is never an answer, whatever it printed (a login error on stdout)
|
|
1815
|
+
if (r.exit_code !== 0) return { ok: false, reason: `the model exited with code ${r.exit_code}${tail ? `: ${clip(tail, 160)}` : ''}` };
|
|
1816
|
+
const answer = String(r.stdout || '');
|
|
1817
|
+
if (!answer.trim()) return { ok: false, reason: 'the model gave an empty answer' };
|
|
1818
|
+
return { ok: true, text: answer };
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// One call through the lineup: each model in turn until one answers, each
|
|
1823
|
+
// with what is left of timeoutMs. command is the claude command the call
|
|
1824
|
+
// wants (its model and effort); the others ride their own defaults. A stop,
|
|
1825
|
+
// or the server stopping its writers, ends it at once. A model that fails
|
|
1826
|
+
// sits out; so does one that said nothing until its time ran out, or for
|
|
1827
|
+
// unheardMs before a stop, unless it answered another call meanwhile. A
|
|
1828
|
+
// model that began to answer and is only slow keeps its place, and one
|
|
1829
|
+
// that answers is back in at once. With no answer, no_model says why:
|
|
1830
|
+
// 'none' when no model is on this computer, else 'failed'; retry says
|
|
1831
|
+
// another one here can still take the next call.
|
|
1832
|
+
async function think(prompt, { root, command = { bin: 'claude', args: claudeArgs }, cwd, timeoutMs = MODEL_TIMEOUT_MS, onProgress = () => {}, signal = null, unheardMs = UNHEARD_MS } = {}) {
|
|
1833
|
+
const st = thinkerState(root);
|
|
1834
|
+
const deadline = Date.now() + timeoutMs;
|
|
1835
|
+
const stops = WRITER_STOPS;
|
|
1836
|
+
const shutDown = () => ({ ok: false, reason: STOPPED_REASON, stopped: true });
|
|
1837
|
+
let last = null;
|
|
1838
|
+
for (const t of thinkerLineup(root)) {
|
|
1839
|
+
if (WRITER_STOPS !== stops) return shutDown();
|
|
1840
|
+
if (benched(st, t.id)) continue;
|
|
1841
|
+
const left = deadline - Date.now();
|
|
1842
|
+
if (left <= 0) break;
|
|
1843
|
+
const started = Date.now();
|
|
1844
|
+
const out = t.lead
|
|
1845
|
+
? await runModel(prompt, { command, cwd, timeoutMs: left, onProgress, signal })
|
|
1846
|
+
: await askThinker(t.id, prompt, { timeoutMs: left, onProgress, signal });
|
|
1847
|
+
if (out.ok) {
|
|
1848
|
+
st.answered.set(t.id, Date.now());
|
|
1849
|
+
st.bench.delete(t.id);
|
|
1850
|
+
return { ...out, engine: t.id };
|
|
1851
|
+
}
|
|
1852
|
+
// the server stopped while this one ran: not the model's failure
|
|
1853
|
+
if (WRITER_STOPS !== stops) return shutDown();
|
|
1854
|
+
// heard: claude's api sent an event back, or another model printed a word
|
|
1855
|
+
const silent = !out.heard && !(st.answered.get(t.id) >= started);
|
|
1856
|
+
if (out.stopped) {
|
|
1857
|
+
if (silent && Date.now() - started >= unheardMs) benchThinker(st, t.id, 'it never started to answer');
|
|
1858
|
+
return { ...out, engine: t.id, ...(silent ? { unheard: true } : {}) };
|
|
1859
|
+
}
|
|
1860
|
+
last = out;
|
|
1861
|
+
if (out.timed_out && !silent) break;
|
|
1862
|
+
benchThinker(st, t.id, out.reason, Boolean(out.missing));
|
|
1863
|
+
}
|
|
1864
|
+
const verdict = thinkerVerdict(root);
|
|
1865
|
+
return { ok: false, reason: last ? last.reason : 'no model here can think right now', no_model: verdict === 'none' ? 'none' : 'failed', retry: verdict === 'ready' };
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1658
1868
|
// ---- checking a bet -------------------------------------------------------------------
|
|
1659
1869
|
|
|
1660
1870
|
function parseBet(text) {
|
|
@@ -2152,6 +2362,14 @@ function ownerConcern(c) {
|
|
|
2152
2362
|
// Every model attempt, writer and skeptic (the kind field), with its lane, is
|
|
2153
2363
|
// kept in the attempts log, the stopped ones too.
|
|
2154
2364
|
async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, concern = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false, writers = deps.writers || SWARM_WRITERS, budgetMs = deps.budgetMs || SWARM_BUDGET_MS, signal = null } = {}) {
|
|
2365
|
+
// the orb's lineup of models thinks, unless a test stubs the model
|
|
2366
|
+
const lineup = !deps.runModel;
|
|
2367
|
+
// no model here can think: said at once, before a fact is read
|
|
2368
|
+
if (lineup) {
|
|
2369
|
+
const verdict = thinkerVerdict(workspaceRoot);
|
|
2370
|
+
if (verdict !== 'ready') return { ok: false, reason: verdict === 'none' ? 'no model is on this computer' : 'every model here failed just now', no_model: verdict, retry: false };
|
|
2371
|
+
}
|
|
2372
|
+
const benchesBefore = lineup ? thinkerState(workspaceRoot).benches : 0;
|
|
2155
2373
|
// the budget runs on the real clock, the one the stop timer uses
|
|
2156
2374
|
const deadline = Date.now() + budgetMs;
|
|
2157
2375
|
const left = () => deadline - Date.now();
|
|
@@ -2159,10 +2377,12 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
|
|
|
2159
2377
|
const judgeMin = Math.min(JUDGE_MIN_LEFT_MS, budgetMs * (JUDGE_MIN_LEFT_MS / SWARM_BUDGET_MS));
|
|
2160
2378
|
// a rework may stay close to the idea it reworks
|
|
2161
2379
|
const avoidTitles = reworkOf ? avoidIn.filter(t => t !== reworkOf) : avoidIn;
|
|
2162
|
-
|
|
2380
|
+
// claude silent for half the budget is not answering this drafting
|
|
2381
|
+
const unheardMs = Math.min(UNHEARD_MS, budgetMs / 2);
|
|
2382
|
+
const run = deps.runModel || ((prompt, opts) => think(prompt, { ...opts, root: workspaceRoot, command: writerCommand(), unheardMs }));
|
|
2163
2383
|
// tests pass runSkeptic; a stubbed runModel without one is also used for the
|
|
2164
2384
|
// skeptic, so a test can never reach the real model by accident
|
|
2165
|
-
const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) =>
|
|
2385
|
+
const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => think(prompt, { ...opts, root: workspaceRoot, command: skepticCommand(), unheardMs }));
|
|
2166
2386
|
const clock = deps.now || Date.now;
|
|
2167
2387
|
const cwd = deps.runModel ? workspaceRoot : quietDir();
|
|
2168
2388
|
const n = Math.max(1, Math.floor(Number(writers)) || 1);
|
|
@@ -2176,6 +2396,9 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
|
|
|
2176
2396
|
const live = new Set();
|
|
2177
2397
|
let turnedDown = 0;
|
|
2178
2398
|
let checking = 0;
|
|
2399
|
+
// writer answers that came back, and writers stopped before the api said a word
|
|
2400
|
+
let answers = 0;
|
|
2401
|
+
let unheard = 0;
|
|
2179
2402
|
let winner = null;
|
|
2180
2403
|
let over = null;
|
|
2181
2404
|
let release;
|
|
@@ -2261,7 +2484,9 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
|
|
|
2261
2484
|
emit({ kind: 'thinking', line: w ? `thinking... ${commas(w)} words written` : 'thinking...', n: w, lane: laneNo });
|
|
2262
2485
|
},
|
|
2263
2486
|
});
|
|
2264
|
-
const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started };
|
|
2487
|
+
const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started, ...(answer.engine ? { engine: answer.engine } : {}) };
|
|
2488
|
+
if (answer.ok) answers += 1;
|
|
2489
|
+
else if (answer.unheard) unheard += 1;
|
|
2265
2490
|
if (!answer.ok) {
|
|
2266
2491
|
saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, ...(answer.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [answer.reason] });
|
|
2267
2492
|
return { failed: answer.reason, stopped: Boolean(answer.stopped), base };
|
|
@@ -2373,6 +2598,13 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
|
|
|
2373
2598
|
Promise.all(lanes).then(() => stop('done'));
|
|
2374
2599
|
await ended;
|
|
2375
2600
|
finish();
|
|
2601
|
+
// out of time with no answer back: the stopped calls end within the stop's
|
|
2602
|
+
// grace, and only then is it known whether claude ever started to answer
|
|
2603
|
+
if (lineup && over === 'budget' && !answers) {
|
|
2604
|
+
let grace;
|
|
2605
|
+
await Promise.race([Promise.all(lanes), new Promise((resolve) => { grace = setTimeout(resolve, STOP_GRACE_MS + 500); })]);
|
|
2606
|
+
clearTimeout(grace);
|
|
2607
|
+
}
|
|
2376
2608
|
|
|
2377
2609
|
if (over === 'stopped') return { ok: false, stopped: true, reason: 'stopped before an idea was picked', bundle };
|
|
2378
2610
|
if (winner) return { ok: true, bet: { ...winner.bet, skeptic: winner.verdict }, bundle, attempts: winner.attempt, lane: winner.lane, checked: true };
|
|
@@ -2386,7 +2618,19 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
|
|
|
2386
2618
|
emit({ kind: 'none', line: 'no draft met the plain rules', say: SWARM_SAY.nothing });
|
|
2387
2619
|
const failedAll = ends.length > 0 && ends.every(e => e.model_failed);
|
|
2388
2620
|
const last = [...ends].reverse().find(e => e.reason);
|
|
2389
|
-
|
|
2621
|
+
// No model answered: every writer's call failed, or the time ran out with
|
|
2622
|
+
// claude never starting to answer. That is the model's trouble, never the
|
|
2623
|
+
// owner's: no_model says so, and retry says another model here can take
|
|
2624
|
+
// the next drafting (one sat out during this one, so it is not the same try).
|
|
2625
|
+
const silent = over === 'budget' && !answers && unheard > 0;
|
|
2626
|
+
if (failedAll || silent) {
|
|
2627
|
+
const reason = failedAll ? ends[0].reason : `no model answered in ${Math.round(budgetMs / 1000)} seconds`;
|
|
2628
|
+
if (!lineup && failedAll) return { ok: false, reason, bundle, no_model: 'failed', retry: false };
|
|
2629
|
+
const verdict = lineup ? thinkerVerdict(workspaceRoot) : 'ready';
|
|
2630
|
+
if (verdict !== 'ready') return { ok: false, reason, bundle, no_model: verdict, retry: false };
|
|
2631
|
+
if (lineup && thinkerState(workspaceRoot).benches > benchesBefore) return { ok: false, reason, bundle, no_model: 'failed', retry: true };
|
|
2632
|
+
if (failedAll) return { ok: false, reason, bundle };
|
|
2633
|
+
}
|
|
2390
2634
|
if (ends.length > 0 && ends.every(e => e.out_of_time) && !turnedDown) return { ok: false, reason: `no time was left to draft inside ${Math.round(budgetMs / 1000)} seconds once the facts were read`, bundle };
|
|
2391
2635
|
if (over === 'budget') return { ok: false, reason: `nothing usable came back in ${Math.round(budgetMs / 1000)} seconds${last ? ` (${last.reason})` : ''}`, bundle };
|
|
2392
2636
|
return { ok: false, reason: `the model's answer was turned down ${plural(turnedDown, 'time')} (${last ? last.reason : 'no answer met the rules'})`, bundle };
|
|
@@ -2854,6 +3098,7 @@ const BACKOFF_MS = 10 * 60 * 1000;
|
|
|
2854
3098
|
// sayTimeoutMs how long a read may take before the plain fallback (15 s)
|
|
2855
3099
|
// loadSeason(root) -> season, the board's 30 days of plays for each ovr (tests stub)
|
|
2856
3100
|
// resolveEngine(name) -> { engine, model }, the member's roster pick (tests stub)
|
|
3101
|
+
// wakeThinkers() forget which models were missing or failing, on a tap on the card that says so
|
|
2857
3102
|
function createBetDesk(deps) {
|
|
2858
3103
|
const now = deps.now || Date.now;
|
|
2859
3104
|
const setTimer = deps.setTimer || ((fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); return t; });
|
|
@@ -2867,7 +3112,7 @@ function createBetDesk(deps) {
|
|
|
2867
3112
|
// a desk with a stubbed idea writer never reaches the real reader either
|
|
2868
3113
|
const readFn = deps.readSaid || (deps.generate
|
|
2869
3114
|
? (async () => ({ ok: false, reason: 'no reader here' }))
|
|
2870
|
-
: (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {} })));
|
|
3115
|
+
: (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {}, ...(deps.sayTimeoutMs ? { timeoutMs: deps.sayTimeoutMs } : {}) })));
|
|
2871
3116
|
const suggestNumbers = deps.suggestScoreboard || (deps.generate
|
|
2872
3117
|
? (() => new Promise(() => {}))
|
|
2873
3118
|
: (opts => generateScoreboardChoices({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
|
|
@@ -2878,6 +3123,7 @@ function createBetDesk(deps) {
|
|
|
2878
3123
|
// the real writer starts on the next turn, so its first reads never hold up a tap
|
|
2879
3124
|
const generate = deps.generate || (opts => new Promise(resolve => setImmediate(resolve)).then(() => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile })));
|
|
2880
3125
|
const log = deps.log || (() => {});
|
|
3126
|
+
const wakeThinkers = deps.wakeThinkers || (() => forgetThinkers(deps.workspaceRoot));
|
|
2881
3127
|
const state = {
|
|
2882
3128
|
status: 'idle',
|
|
2883
3129
|
current: null,
|
|
@@ -3408,6 +3654,10 @@ function createBetDesk(deps) {
|
|
|
3408
3654
|
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
|
|
3409
3655
|
}
|
|
3410
3656
|
const onWait = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'wait';
|
|
3657
|
+
// the card on screen says no model answered
|
|
3658
|
+
const onNoModel = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'missed' && Boolean(state.current.no_model);
|
|
3659
|
+
// the line the calm line shows when one model did not answer and another takes over
|
|
3660
|
+
const NEXT_MODEL_LINE = 'the model here did not answer, so another one is trying.';
|
|
3411
3661
|
|
|
3412
3662
|
// Words typed on the calm line are a note for the idea being written: no
|
|
3413
3663
|
// read, no new card. The next drafting starts with them (one under way
|
|
@@ -3480,19 +3730,22 @@ function createBetDesk(deps) {
|
|
|
3480
3730
|
return { progress: lines, started_at: startedAt, waited_ms: Math.max(0, now() - startedAt) };
|
|
3481
3731
|
}
|
|
3482
3732
|
|
|
3483
|
-
// The wait ended with no draft that met the plain rules
|
|
3484
|
-
//
|
|
3733
|
+
// The wait ended with no draft that met the plain rules: a card the owner
|
|
3734
|
+
// can act on, never an error. When no model answered at all (noModel:
|
|
3735
|
+
// 'none', no model on this computer, or 'failed', the one here did not
|
|
3736
|
+
// answer) the card says that instead, and what to do.
|
|
3485
3737
|
const MISSED_CARD = { what: 'no idea came together this time.' };
|
|
3738
|
+
const NO_MODEL_CARD = { what: 'the orb needs a model signed in on this computer.' };
|
|
3486
3739
|
// It remembers the wait it ended (after, and the id that wait was served
|
|
3487
3740
|
// under), so words the owner was typing there still count.
|
|
3488
|
-
function showMissed() {
|
|
3741
|
+
function showMissed(noModel = null) {
|
|
3489
3742
|
const was = state.current && state.current.kind === 'wait' ? state.current : null;
|
|
3490
3743
|
const servedWait = was && state.served && state.served.kind === 'wait' ? state.served.id : null;
|
|
3491
|
-
state.current = { kind: 'missed', id: `missed:${now()}`, ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
|
|
3744
|
+
state.current = { kind: 'missed', id: `missed:${now()}`, ...(noModel ? { no_model: noModel } : {}), ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
|
|
3492
3745
|
state.status = 'ready';
|
|
3493
3746
|
state.mission = null;
|
|
3494
3747
|
state.error = null;
|
|
3495
|
-
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed' });
|
|
3748
|
+
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed', ...(noModel ? { no_model: noModel } : {}) });
|
|
3496
3749
|
}
|
|
3497
3750
|
|
|
3498
3751
|
// The owner asked for something (a sharper idea, words on a card): a
|
|
@@ -3601,12 +3854,24 @@ function createBetDesk(deps) {
|
|
|
3601
3854
|
}
|
|
3602
3855
|
if (!out.ok) {
|
|
3603
3856
|
// a failure stays in the log; the owner never sees the reason
|
|
3604
|
-
log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
|
|
3857
|
+
log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200), ...(out.no_model ? { no_model: out.no_model } : {}) });
|
|
3605
3858
|
emit({ kind: 'failed', line: out.reason });
|
|
3859
|
+
// one model did not answer and another one here can: the next
|
|
3860
|
+
// drafting starts on it now, with what this one was asked, and the
|
|
3861
|
+
// calm line says so instead of ending
|
|
3862
|
+
if (out.no_model && out.retry) {
|
|
3863
|
+
if (gen.asked && !state.pendingSharper) state.pendingSharper = asks;
|
|
3864
|
+
if (onWait()) {
|
|
3865
|
+
state.current.lines = [...(state.current.lines || []), { at: now(), line: NEXT_MODEL_LINE }];
|
|
3866
|
+
state.current.waitsOn = null;
|
|
3867
|
+
}
|
|
3868
|
+
return pump();
|
|
3869
|
+
}
|
|
3606
3870
|
state.failures += 1;
|
|
3607
3871
|
if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
|
|
3608
|
-
// the owner is waiting on the calm line: the wait ends in a card he
|
|
3609
|
-
|
|
3872
|
+
// the owner is waiting on the calm line: the wait ends in a card he
|
|
3873
|
+
// can act on, the one that says no model answered when none did
|
|
3874
|
+
if (onWait()) showMissed(out.no_model || null);
|
|
3610
3875
|
return pump();
|
|
3611
3876
|
}
|
|
3612
3877
|
const item = { id: newId(), bet: out.bet, created_at: now(), state_key: key, ...(gen.asked ? { asked: true } : {}), ...(ownerNote && reworkOf ? { rework_of: reworkOf } : {}), ...(ownerNote ? { owner_note: ownerNote } : {}) };
|
|
@@ -3677,6 +3942,8 @@ function createBetDesk(deps) {
|
|
|
3677
3942
|
// later: not now on a yes card.
|
|
3678
3943
|
function next({ sharper = false, later = false } = {}) {
|
|
3679
3944
|
if (state.status === 'starting') return { ok: true, busy: true, ...view() };
|
|
3945
|
+
// a tap on the card that says no model answered looks again, so one signed in since then is found
|
|
3946
|
+
if (onNoModel()) wakeThinkers();
|
|
3680
3947
|
const onScreen = state.status === 'ready' && state.current ? state.current : null;
|
|
3681
3948
|
// try again after a wait that ended with a note on it: the note goes into the next
|
|
3682
3949
|
// drafting, or the calm line joins the one already writing it, never a second
|
|
@@ -4020,7 +4287,7 @@ function createBetDesk(deps) {
|
|
|
4020
4287
|
id: cur ? cur.id : null,
|
|
4021
4288
|
kind,
|
|
4022
4289
|
bet: kind === 'bet' ? cur.bet : null,
|
|
4023
|
-
job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...MISSED_CARD } : null)),
|
|
4290
|
+
job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...(cur.no_model ? NO_MODEL_CARD : MISSED_CARD) } : null)),
|
|
4024
4291
|
question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
|
|
4025
4292
|
report: kind === 'report' ? {
|
|
4026
4293
|
what: `${cur.board.name}: what is it this week?`,
|
|
@@ -4083,7 +4350,9 @@ function createBetDesk(deps) {
|
|
|
4083
4350
|
|
|
4084
4351
|
// Read first. A question back or an unclear message is answered on the
|
|
4085
4352
|
// same card and saves nothing; an answer or an instruction is acted on.
|
|
4086
|
-
// A
|
|
4353
|
+
// A model that answered with a read of no use asks him to say it another
|
|
4354
|
+
// way. A read no model answered (it failed, was slow, or none is here) is
|
|
4355
|
+
// never put on his words: the reply says what happened, and nothing is saved.
|
|
4087
4356
|
const QUESTION_HINT = 'his message ends with a question mark: it is a question back, not an answer';
|
|
4088
4357
|
async function readTurn(t, text, card) {
|
|
4089
4358
|
const thread = t.turns.slice(-THREAD_TURNS);
|
|
@@ -4092,16 +4361,23 @@ function createBetDesk(deps) {
|
|
|
4092
4361
|
// more with that said, and if it still reads as an answer, fall back
|
|
4093
4362
|
if (read && read.ok && read.kind === 'answer' && /\?\s*$/.test(text)) {
|
|
4094
4363
|
read = await readWithin({ text, card, thread, hint: QUESTION_HINT });
|
|
4095
|
-
if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer' };
|
|
4364
|
+
if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer', answered: true };
|
|
4096
4365
|
}
|
|
4097
4366
|
if (read && read.ok && (read.kind === 'answer' || read.kind === 'instruction')) return { act: read };
|
|
4098
|
-
|
|
4099
|
-
|
|
4367
|
+
let reply;
|
|
4368
|
+
let kind;
|
|
4369
|
+
if (read && (read.ok || read.answered)) {
|
|
4370
|
+
reply = (read.ok && plainReply(read.reply)) || SAY_FALLBACK;
|
|
4371
|
+
kind = reply === SAY_FALLBACK ? 'unclear' : read.kind;
|
|
4372
|
+
} else {
|
|
4373
|
+
reply = read && read.no_model && !read.retry ? SAY_NO_MODEL : SAY_NOT_HEARD;
|
|
4374
|
+
kind = 'unheard';
|
|
4375
|
+
}
|
|
4100
4376
|
const suggestions = read && read.ok && reply !== SAY_FALLBACK
|
|
4101
4377
|
? plainSuggestions((Array.isArray(read.suggestions) ? read.suggestions : []).map(you).filter(sug => !(card.buttons || []).some(b => sameLabel(b, sug))))
|
|
4102
4378
|
: [];
|
|
4103
4379
|
addTurns(t, { who: 'owner', text }, { who: 'orb', text: reply });
|
|
4104
|
-
return { stay: { ok: true, stay: true, kind
|
|
4380
|
+
return { stay: { ok: true, stay: true, kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
|
|
4105
4381
|
}
|
|
4106
4382
|
|
|
4107
4383
|
// On a question it is the answer; on an idea, a note the writer reworks it
|
|
@@ -4117,6 +4393,8 @@ function createBetDesk(deps) {
|
|
|
4117
4393
|
if (card !== undefined && card !== cur.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. here is the one on screen now.' };
|
|
4118
4394
|
// on the calm line there is nothing to answer: the words are a note for the idea being written, read by no model
|
|
4119
4395
|
if (cur.kind === 'wait') return noteOnWait(cur, said);
|
|
4396
|
+
// words on the card that says no model answered look again first, so one signed in since then reads them
|
|
4397
|
+
if (onNoModel()) wakeThinkers();
|
|
4120
4398
|
let kind = cur.kind || 'bet';
|
|
4121
4399
|
log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
|
|
4122
4400
|
const t = threadFor(cur.id);
|
|
@@ -4541,7 +4819,7 @@ function createBetDesk(deps) {
|
|
|
4541
4819
|
if (kind === 'report') return gmCard.report({ id: cur.id, title: v.report.what, body: v.report.why });
|
|
4542
4820
|
if (kind === 'choice') return gmCard.choice({ id: cur.id, items: v.choice.options });
|
|
4543
4821
|
if (kind === 'wait') return gmCard.wait({ id: cur.id, ...waitProgress(cur), thread: waitThread(cur) });
|
|
4544
|
-
if (kind === 'missed') return gmCard.missed({ id: cur.id });
|
|
4822
|
+
if (kind === 'missed') return gmCard.missed({ id: cur.id, noModel: cur.no_model || null });
|
|
4545
4823
|
if (kind === 'yes') {
|
|
4546
4824
|
const j = cur.job;
|
|
4547
4825
|
return gmCard.yes({ id: cur.id, title: v.job.what, body: v.job.why, job: jobFacts(j.owner), request: { job: j.mission_id, key: j.key, hash: j.lock_hash || null } });
|
package/lib/gm-card.js
CHANGED
|
@@ -166,10 +166,20 @@ function wait({ id, progress = [], started_at = null, waited_ms = null, thread =
|
|
|
166
166
|
return { id, kind: 'wait', asks: id, title: 'writing your next idea.', body: null, options: [], input: sayInput({ placeholder: 'add a note for this idea' }), watch: true, progress, started_at, waited_ms, thread };
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
// the wait ended with nothing usable: a way to try again, and the box
|
|
170
|
-
|
|
169
|
+
// the wait ended with nothing usable: a way to try again, and the box.
|
|
170
|
+
// noModel: no model answered at all, so the card says what it needs
|
|
171
|
+
// instead: 'none' when there is no model on this computer, 'failed' when
|
|
172
|
+
// the one here did not answer
|
|
173
|
+
const NO_MODEL_TITLE = 'the orb needs a model signed in on this computer.';
|
|
174
|
+
const NO_MODEL_BODY = {
|
|
175
|
+
none: 'there is none here yet. once one is set up, tap try again.',
|
|
176
|
+
failed: 'the model here did not answer. check that it is signed in, then tap try again.',
|
|
177
|
+
};
|
|
178
|
+
function missed({ id, noModel = null }) {
|
|
171
179
|
return {
|
|
172
|
-
id, kind: 'missed', asks: id,
|
|
180
|
+
id, kind: 'missed', asks: id,
|
|
181
|
+
title: noModel ? NO_MODEL_TITLE : 'no idea came together this time.',
|
|
182
|
+
body: noModel ? NO_MODEL_BODY[noModel] || NO_MODEL_BODY.failed : 'try again, or say what you want one about.',
|
|
173
183
|
options: [answer(id, { id: 'again', label: 'try again', action: 'next', style: 'primary' })],
|
|
174
184
|
input: sayInput(),
|
|
175
185
|
};
|
package/lib/zen-apply.js
CHANGED
|
@@ -31,6 +31,7 @@ const {
|
|
|
31
31
|
sideCopyLines,
|
|
32
32
|
scheduledCopies,
|
|
33
33
|
runningCopies,
|
|
34
|
+
shortPlace,
|
|
34
35
|
untrackedFiles,
|
|
35
36
|
ABANDONED_DAYS,
|
|
36
37
|
abandonedDaysFromEnv,
|
|
@@ -81,6 +82,13 @@ function identityEnv(root) {
|
|
|
81
82
|
return env;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// Where zen put your copy of a clashing note, said from the repo root so the
|
|
86
|
+
// line fits the pane, or from home when it sits outside the repo.
|
|
87
|
+
function savedPlace(root, file) {
|
|
88
|
+
const rel = path.relative(root, file);
|
|
89
|
+
return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : shortPlace(file);
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
function archiveStashes(root, tag) {
|
|
85
93
|
const shas = (gitOut(['stash', 'list', '--format=%H'], root) || '').split('\n').filter(Boolean);
|
|
86
94
|
if (!shas.length) return null;
|
|
@@ -444,7 +452,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
444
452
|
for (const p of clashed) {
|
|
445
453
|
const mine = path.join(dir, 'mine', p);
|
|
446
454
|
if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
|
|
447
|
-
else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
|
|
455
|
+
else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, mine)}`);
|
|
448
456
|
}
|
|
449
457
|
for (const p of missed) {
|
|
450
458
|
const mine = path.join(dir, 'mine', p);
|
|
@@ -452,9 +460,9 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
452
460
|
if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
|
|
453
461
|
notes.push(`${p} moved to ${to}, and your edits went with it`);
|
|
454
462
|
} else if (to) {
|
|
455
|
-
notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
463
|
+
notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
|
|
456
464
|
} else {
|
|
457
|
-
notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
|
|
465
|
+
notes.push(`${p} no longer fits the new changes; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
|
|
458
466
|
}
|
|
459
467
|
}
|
|
460
468
|
}
|
|
@@ -464,7 +472,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
|
|
|
464
472
|
// A copy that matches the incoming file loses nothing, so say nothing.
|
|
465
473
|
if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
|
|
466
474
|
else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
|
|
467
|
-
else notes.push(`kept the new ${p}; your copy is in ${saved}`);
|
|
475
|
+
else notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, saved)}`);
|
|
468
476
|
} else {
|
|
469
477
|
fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
|
|
470
478
|
fs.copyFileSync(saved, path.join(root, p));
|
package/lib/zen.js
CHANGED
package/package.json
CHANGED