flowviant 0.117.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/bin/cli.mjs +37 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +2 -2
- package/bin/lib/agentPlan.mjs +78 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/daemonLocks.mjs +5 -3
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +46 -4
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +61 -4
- package/bin/lib/prompts.mjs +164 -41
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +35 -16
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +27 -9
- package/bin/lib/runtimeCodex.mjs +27 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +28 -7
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +5 -0
- package/bin/lib/workAgentTurnExecution.mjs +93 -22
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurns.mjs +60 -4
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workPlaces.mjs +18 -2
- package/bin/lib/workSessionTurns.mjs +148 -13
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/npm-shrinkwrap.json +12 -2
- package/package.json +7 -3
|
@@ -23,6 +23,7 @@ import { gitOrNull } from './git.mjs';
|
|
|
23
23
|
import { stashCard } from './agentCards.mjs';
|
|
24
24
|
import { c } from './ui.mjs';
|
|
25
25
|
import { runTurn } from './runTurn.mjs';
|
|
26
|
+
import { killGroup } from './processGroup.mjs';
|
|
26
27
|
import { SYSTEM_AGENT_FOR, AGENT_TASK_KICKOFF, AGENT_TASK_SPEC, AGENT_HUMAN_KICKOFF, withProjectContext } from './prompts.mjs';
|
|
27
28
|
import { AGENT_TASK_KINDS, agentTaskKindOf, unknownAgentTaskKind } from './agentTaskKinds.mjs';
|
|
28
29
|
import { knowledgeDirFor } from './knowledgeFiles.mjs';
|
|
@@ -30,7 +31,9 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
|
30
31
|
import { canRun, runtimeById } from './runtimes.mjs';
|
|
31
32
|
import { toolEventOf, CLAUDE_TOOL_PROSE_KINDS } from './runtimeEvents.mjs';
|
|
32
33
|
import { makeTraceRelay } from './trace.mjs';
|
|
33
|
-
import { readBaseTools, prepareAgentTools } from './projectTools.mjs';
|
|
34
|
+
import { mcpJsonServerNamesAbove, readBaseTools, prepareAgentTools } from './projectTools.mjs';
|
|
35
|
+
import { TURN_PROFILES } from './turnProfile.mjs';
|
|
36
|
+
import { postureToolsNote } from './postureTools.mjs';
|
|
34
37
|
import { runTurnResumingOnce } from './resumeLost.mjs';
|
|
35
38
|
import { INTERNAL_WAIT_CONTRACT, runAgentWithInternalWaits } from './agentInternalWait.mjs';
|
|
36
39
|
import { CODEX_THREAD_RE } from './workSessionRuntime.mjs';
|
|
@@ -41,6 +44,7 @@ import { ensureArtifactDir, ensureFenceScratch } from './artifacts.mjs';
|
|
|
41
44
|
import { agentBrowserHome } from './noWindowEnv.mjs';
|
|
42
45
|
|
|
43
46
|
export function createAgentTurnExecution({
|
|
47
|
+
makeBrief,
|
|
44
48
|
baseRef,
|
|
45
49
|
baseDir,
|
|
46
50
|
repoRoot,
|
|
@@ -107,7 +111,9 @@ export function createAgentTurnExecution({
|
|
|
107
111
|
* `releaseSlot` hands back the admission reservation the lane took on this
|
|
108
112
|
* turn's behalf, at the moment the CLI actually exists. Idempotent.
|
|
109
113
|
*/
|
|
110
|
-
|
|
114
|
+
/** `run` is the lane's record of this turn (`interrupted`, set by
|
|
115
|
+
* `interruptAgentTurns`); absent, nothing can interrupt it. */
|
|
116
|
+
const runAgentTurnInPlace = async (job, { turnId, agentId, place, releaseSlot, postAgentTurn, measured, run = { interrupted: false } }) => {
|
|
111
117
|
// WORK THAT HAS BEGUN LIVES ON EXACTLY ONE BOX — read BEFORE anything is
|
|
112
118
|
// cut, because `placeWtFor` would cut a rival branch of the same name. The
|
|
113
119
|
// rule and its three-state branch measurement: workAgentTurnBegun.mjs.
|
|
@@ -375,8 +381,8 @@ export function createAgentTurnExecution({
|
|
|
375
381
|
/**
|
|
376
382
|
* Prose the structured event will carry anyway, dropped so a read does
|
|
377
383
|
* not render twice — but ONLY on a runtime whose stream reaches
|
|
378
|
-
* `onToolEvent`
|
|
379
|
-
*
|
|
384
|
+
* `onToolEvent` here. Codex uses completed activity; agy has its own
|
|
385
|
+
* parser. Dropping their tool prose would blank their agents' traces.
|
|
380
386
|
* `parse: null` is exactly the claudeStream.mjs stream path. See
|
|
381
387
|
* CLAUDE_TOOL_PROSE_KINDS.
|
|
382
388
|
*/
|
|
@@ -408,6 +414,9 @@ export function createAgentTurnExecution({
|
|
|
408
414
|
* ever, which holds the self-update and the tray's install for ever.
|
|
409
415
|
*/
|
|
410
416
|
const spawnedChildren = new Set();
|
|
417
|
+
/** The CLI, unless a person already asked to stop this turn (Interrupt
|
|
418
|
+
* now): then nothing starts — no fresh retry, no continuation. */
|
|
419
|
+
const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
|
|
411
420
|
/**
|
|
412
421
|
* WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
|
|
413
422
|
*
|
|
@@ -426,6 +435,8 @@ export function createAgentTurnExecution({
|
|
|
426
435
|
/** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) — rides
|
|
427
436
|
* every settle `usage` rides, for the same reason; null sends no key. */
|
|
428
437
|
let model = null;
|
|
438
|
+
const brief = makeBrief?.({ agentId, turnId, runtime: rt, model: () => model ?? job.model });
|
|
439
|
+
brief?.append('prompt', job.task ? { title: job.task.title, message: job.body, discussion: job.cardThread, brief: job.task.brief, criteria: job.task.criteria } : job.body);
|
|
429
440
|
/**
|
|
430
441
|
* THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
|
|
431
442
|
* Codex's `out` is every message plus stderr plus error text, and
|
|
@@ -437,11 +448,32 @@ export function createAgentTurnExecution({
|
|
|
437
448
|
/** The agent's artifact directory as it stood before this turn — the
|
|
438
449
|
* tab lane's rule, in the agent's own worktree (2026-09-22). */
|
|
439
450
|
const artifactsBefore = beforeArtifacts(wt);
|
|
451
|
+
const artifactInstructions = getArtifactsAccepted()
|
|
452
|
+
? await artifacts.prepare?.({ placeDir: wt, before: artifactsBefore, agentId }) ?? '' : '';
|
|
440
453
|
let projectTools = null;
|
|
441
454
|
try {
|
|
442
455
|
// A turn reads the base ref afresh. Changes the agent has made to its
|
|
443
456
|
// own worktree cannot silently change the next turn's tools.
|
|
444
457
|
projectTools = prepareAgentTools(readBaseTools(repoRoot, baseRef()), rt, process.env, wt, { codexHome: codexAgentHome });
|
|
458
|
+
const mountsProjectMcp = TURN_PROFILES[posture]?.projectMcp === true && (rt === 'claude' || rt === 'codex');
|
|
459
|
+
// A fenced posture on a runner that ignores its own file there (Codex's
|
|
460
|
+
// `--ignore-user-config`) takes the servers as `fencedMount` instead.
|
|
461
|
+
const fencedMount = mountsProjectMcp && posture !== 'build' ? projectTools.fencedMount : null;
|
|
462
|
+
// THE MACHINE'S OWN SERVERS ON CLAUDE (2026-10-11, the owner): the turn
|
|
463
|
+
// is not strict, and every `.mcp.json` name from the worktree up is
|
|
464
|
+
// disabled so only the base copy speaks for the project. Null (an
|
|
465
|
+
// unboundable walk) mounts strictly: the base copy alone.
|
|
466
|
+
const claudeDisabled = mountsProjectMcp && rt === 'claude' ? mcpJsonServerNamesAbove(wt) : null;
|
|
467
|
+
// What this card's posture withholds, said to the agent — with the
|
|
468
|
+
// servers it did and did not mount, and why — so it relays the fence
|
|
469
|
+
// instead of inventing a setting (postureTools.mjs). Empty for a code card.
|
|
470
|
+
const toolsNote = postureToolsNote({
|
|
471
|
+
posture,
|
|
472
|
+
runtime: rt,
|
|
473
|
+
mounted: !mountsProjectMcp ? [] : fencedMount ? fencedMount.mounted : projectTools.mcpServers,
|
|
474
|
+
machine: mountsProjectMcp && (rt === 'codex' || claudeDisabled != null),
|
|
475
|
+
unmounted: [...projectTools.mcpUnmounted.map((u) => ({ ...u, source: 'project' })), ...(fencedMount ? fencedMount.unmounted : [])],
|
|
476
|
+
});
|
|
445
477
|
const agentTurnArgs = {
|
|
446
478
|
prompt:
|
|
447
479
|
job.kind === 'task' && job.task
|
|
@@ -451,6 +483,8 @@ export function createAgentTurnExecution({
|
|
|
451
483
|
// The card's discussion (0.106.0), printed inside THE CARD by
|
|
452
484
|
// the same builder the stash above wrote down — and the files
|
|
453
485
|
// on it (0.112.0), after it, the same way.
|
|
486
|
+
resumeBody: job.body,
|
|
487
|
+
investigationContext: job.investigationContext,
|
|
454
488
|
cardThread: job.cardThread,
|
|
455
489
|
cardFiles: files.card,
|
|
456
490
|
position: job.position ?? 1,
|
|
@@ -458,7 +492,10 @@ export function createAgentTurnExecution({
|
|
|
458
492
|
})
|
|
459
493
|
: AGENT_HUMAN_KICKOFF({
|
|
460
494
|
agentName: job.agentName,
|
|
495
|
+
// INTERRUPT NOW: the previous run was stopped to send this.
|
|
496
|
+
interrupted: job.interrupted === true,
|
|
461
497
|
message: job.body ?? '',
|
|
498
|
+
investigationContext: job.investigationContext,
|
|
462
499
|
askedByName: job.askedByName,
|
|
463
500
|
task: job.task,
|
|
464
501
|
// The discussion on the one card this message is about, in a
|
|
@@ -476,26 +513,28 @@ export function createAgentTurnExecution({
|
|
|
476
513
|
// different things about the same directory.
|
|
477
514
|
// Spoken to the CLI that runs it (0.115.0): a mockup on Codex is
|
|
478
515
|
// told it is Codex, never Claude.
|
|
479
|
-
system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, runtimeById(rt)?.label), {
|
|
516
|
+
system: withProjectContext(SYSTEM_AGENT_FOR(taskKind, runtimeById(rt)?.label, { prerequisitesAccepted: job.prerequisitesAccepted === true }), {
|
|
480
517
|
knowledgeDir: knowledgeDirFor(repoRoot),
|
|
481
518
|
// ARTIFACTS (0.94.0), while the server can show one — an agent's
|
|
482
519
|
// land on its page, under the facts row.
|
|
483
520
|
artifacts: getArtifactsAccepted(),
|
|
484
|
-
}) + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
|
|
521
|
+
}) + (toolsNote ? `\n\n${toolsNote}` : '') + (artifactInstructions ? `\n\n${artifactInstructions}` : '') + `\n\n${INTERNAL_WAIT_CONTRACT}` + (projectTools.instructions ? `\n\n${projectTools.instructions}` : ''),
|
|
485
522
|
agentTools: projectTools,
|
|
486
|
-
...(projectTools.codexHome ? { mcpEnv: { CODEX_HOME: projectTools.codexHome } } : {}),
|
|
523
|
+
...((projectTools.codexHome || fencedMount) ? { mcpEnv: { ...(projectTools.codexHome ? { CODEX_HOME: projectTools.codexHome } : {}), ...(fencedMount?.env ?? {}) } } : {}),
|
|
487
524
|
// NOBODY IS AT THIS SCREEN (2026-09-29): no display, BROWSER=none, and
|
|
488
525
|
// a browser profile of the agent's own, kept beside its Codex home —
|
|
489
526
|
// so a Chrome it starts to look at its page never reaches the
|
|
490
527
|
// person's (noWindowEnv.mjs).
|
|
491
528
|
browserHome: agentBrowserHome(sessionMetaPath, wt, agentId),
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
529
|
+
// Reviewed project servers and the machine's configured servers, on
|
|
530
|
+
// initial cards, answers and review iterations alike.
|
|
531
|
+
...(mountsProjectMcp && rt === 'claude'
|
|
532
|
+
? {
|
|
533
|
+
mcpArgs: [...(claudeDisabled ? [] : ['--strict-mcp-config']), '--mcp-config', projectTools.mcpPath],
|
|
534
|
+
projectMcpServers: projectTools.mcpServers,
|
|
535
|
+
...(claudeDisabled ? { machineMcp: claudeDisabled } : {}),
|
|
536
|
+
}
|
|
537
|
+
: fencedMount ? { mcpArgs: fencedMount.args } : {}),
|
|
499
538
|
knowledgeDir: knowledgeDirFor(repoRoot),
|
|
500
539
|
// The kind's posture, as the one turn profile (turnProfile.mjs):
|
|
501
540
|
// build for code, the fenced design/research postures otherwise.
|
|
@@ -518,6 +557,7 @@ export function createAgentTurnExecution({
|
|
|
518
557
|
// narrator keeps: overwritten, never appended, and ignored by the
|
|
519
558
|
// board past ~90 seconds.
|
|
520
559
|
onActivity: (a) => {
|
|
560
|
+
brief?.append(a?.kind ?? 'activity', a?.full ?? a?.label);
|
|
521
561
|
const line = a?.label;
|
|
522
562
|
if (!line) return;
|
|
523
563
|
// THE TRACE TAKES EVERY LINE; the pulse takes one every two
|
|
@@ -543,7 +583,13 @@ export function createAgentTurnExecution({
|
|
|
543
583
|
// Workbench's tool cards are built from, scrubbed at collection by
|
|
544
584
|
// the builder itself (bounded window BEFORE its caps; see
|
|
545
585
|
// toolEventOf). A tool it does not know pushes nothing.
|
|
546
|
-
onToolEvent: (name, input) => {
|
|
586
|
+
onToolEvent: (name, input, meta) => {
|
|
587
|
+
brief?.append(meta?.result ? 'tool result' : 'tool call', meta?.result
|
|
588
|
+
? { id: meta.id, exitCode: meta.exitCode, isError: meta.isError, output: meta.output }
|
|
589
|
+
: { name, input });
|
|
590
|
+
// Codex request/result pairs feed capture/planner evidence. This
|
|
591
|
+
// append-only agent trace already relays its completed activity.
|
|
592
|
+
if (meta?.runtime === 'codex') return;
|
|
547
593
|
trace.tool(toolEventOf(name, input, wt, envScrub));
|
|
548
594
|
},
|
|
549
595
|
// The CLI's own per-turn token counts, off the `result` event. One
|
|
@@ -566,6 +612,8 @@ export function createAgentTurnExecution({
|
|
|
566
612
|
// (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
|
|
567
613
|
// startup probe and its Terminal tabs.
|
|
568
614
|
onSpawn: (ch) => {
|
|
615
|
+
brief?.running(true);
|
|
616
|
+
ch.once('close', () => { if (child === ch) brief?.running(false); });
|
|
569
617
|
child = ch;
|
|
570
618
|
spawnedChildren.add(ch);
|
|
571
619
|
// The AGENT it serves — what the machine snapshot charges this
|
|
@@ -583,6 +631,8 @@ export function createAgentTurnExecution({
|
|
|
583
631
|
// and a place id IS one. It is what lets a hard stop actually reach
|
|
584
632
|
// the CLI — see `retireWorkSessions`.
|
|
585
633
|
agentChildren.set(place, ch);
|
|
634
|
+
// Asked to stop between the check and the spawn.
|
|
635
|
+
if (run.interrupted) killGroup(ch);
|
|
586
636
|
},
|
|
587
637
|
};
|
|
588
638
|
/**
|
|
@@ -597,7 +647,7 @@ export function createAgentTurnExecution({
|
|
|
597
647
|
* MENTIONS a missing session is never run twice.
|
|
598
648
|
*/
|
|
599
649
|
out = await runAgentWithInternalWaits(
|
|
600
|
-
(args, continuing) => continuing ?
|
|
650
|
+
(args, continuing) => continuing ? runCli(args) : runTurnResumingOnce(runCli, args, {
|
|
601
651
|
// A Codex write-up always runs fresh over its copy (codexResearch
|
|
602
652
|
// .mjs), so there is no lost conversation to retry it for.
|
|
603
653
|
resume: rt === 'codex' && posture === 'research' ? false : args.resume,
|
|
@@ -615,6 +665,7 @@ export function createAgentTurnExecution({
|
|
|
615
665
|
} }
|
|
616
666
|
);
|
|
617
667
|
} finally {
|
|
668
|
+
await brief?.stop();
|
|
618
669
|
// What the CLI counted, for the lane's catch if anything below throws.
|
|
619
670
|
measured.usage = usage;
|
|
620
671
|
measured.model = model;
|
|
@@ -664,16 +715,36 @@ export function createAgentTurnExecution({
|
|
|
664
715
|
* floor: it must never delay the settle behind it.
|
|
665
716
|
*/
|
|
666
717
|
void reportSessionWorktree(place).catch(() => {});
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
670
|
-
void artifacts
|
|
671
|
-
.report({ placeDir: wt, before: artifactsBefore, agentId, turnId })
|
|
672
|
-
.catch(() => {});
|
|
718
|
+
// A file card delivers uploaded bytes, not an in-memory retry. Otherwise
|
|
719
|
+
// its dependent card can start while Review has nothing to show.
|
|
720
|
+
await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null });
|
|
673
721
|
}
|
|
674
722
|
|
|
675
723
|
|
|
676
724
|
const commits = commitsBetween(wt, before);
|
|
725
|
+
/**
|
|
726
|
+
* A PERSON INTERRUPTED IT (Interrupt now). Whatever the CLI printed was
|
|
727
|
+
* cut off, so there is no outcome to read: the turn reports `interrupted`
|
|
728
|
+
* with what it measured — the commits it made, its spend, the branch —
|
|
729
|
+
* and leaves the worktree exactly as the stopped run left it. The message
|
|
730
|
+
* sent to interrupt it runs next, here, resuming this conversation. No
|
|
731
|
+
* limit, no park and no review entry: none of that happened.
|
|
732
|
+
*/
|
|
733
|
+
if (run.interrupted) {
|
|
734
|
+
measured.ranCli = spawnedChildren.size > 0;
|
|
735
|
+
await postAgentTurn({
|
|
736
|
+
turnId,
|
|
737
|
+
outcome: 'nothing',
|
|
738
|
+
interrupted: true,
|
|
739
|
+
ran: spawnedChildren.size > 0,
|
|
740
|
+
commits,
|
|
741
|
+
...(usage ? { usage } : {}),
|
|
742
|
+
...(model ? { model } : {}),
|
|
743
|
+
...(branch ? { branch } : {}),
|
|
744
|
+
worktree: wt,
|
|
745
|
+
});
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
677
748
|
// WHAT THE TURN REPORTS is one decision, off what was measured here
|
|
678
749
|
// (workAgentTurnOutcome.mjs). The side effects it implies stay on this
|
|
679
750
|
// side: the machine's limit state, the park, and the review-entry beat.
|
|
@@ -25,6 +25,7 @@ import { limitLine } from './cliLimit.mjs';
|
|
|
25
25
|
import { parseTurnResult } from './agentPlan.mjs';
|
|
26
26
|
import { changedArtifacts, scanArtifacts } from './artifacts.mjs';
|
|
27
27
|
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
28
|
+
import { usableImageArtifact } from './imageArtifact.mjs';
|
|
28
29
|
|
|
29
30
|
export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, model = null, commits, artifactsBefore, branch, wt }) {
|
|
30
31
|
const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
|
|
@@ -110,7 +111,8 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
|
|
|
110
111
|
*/
|
|
111
112
|
if (res.outcome === 'delivered' && kind.artifact) {
|
|
112
113
|
const want = kind.artifact.match;
|
|
113
|
-
const has = (l) => l.some((e) => want.test(e.name)
|
|
114
|
+
const has = (l) => l.some((e) => want.test(e.name)
|
|
115
|
+
&& (kind.posture !== 'image' || usableImageArtifact(e)));
|
|
114
116
|
const standing = scanArtifacts(wt);
|
|
115
117
|
const wrote = has(changedArtifacts(artifactsBefore, standing));
|
|
116
118
|
const present = wrote || ((job.kind !== 'task' || job.redo === true) && has(standing));
|
|
@@ -126,11 +128,18 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
|
|
|
126
128
|
});
|
|
127
129
|
}
|
|
128
130
|
}
|
|
131
|
+
const prerequisite = res.prerequisite;
|
|
132
|
+
const cannotRequest = prerequisite && job.prerequisitesAccepted !== true
|
|
133
|
+
? 'This server does not support prerequisite-card requests yet. This work has not been delivered.'
|
|
134
|
+
: prerequisite?.taskKind === 'image' && (job.runtime ?? 'claude') !== 'codex'
|
|
135
|
+
? 'This agent runs on a CLI that cannot generate images. Image cards require Codex’s own image generation; this agent cannot run the requested texture work.'
|
|
136
|
+
: null;
|
|
129
137
|
return report(
|
|
130
138
|
{
|
|
131
139
|
turnId,
|
|
140
|
+
...(prerequisite && !cannotRequest ? { prerequisite } : {}),
|
|
132
141
|
outcome: res.outcome,
|
|
133
|
-
answer: envScrub(res.answer ?? '').slice(0, 8000),
|
|
142
|
+
answer: cannotRequest ?? envScrub(res.answer ?? '').slice(0, 8000),
|
|
134
143
|
...(commits.length ? { commits } : {}),
|
|
135
144
|
// SCRUBBED like the answer beside it: a raised card lands in the
|
|
136
145
|
// project doc every member reads, and a brief quoting the `.env` the
|
|
@@ -25,6 +25,8 @@ import { c, note } from './ui.mjs';
|
|
|
25
25
|
import { createAgentTurnReports } from './workAgentTurnReports.mjs';
|
|
26
26
|
import { createAgentTurnExecution } from './workAgentTurnExecution.mjs';
|
|
27
27
|
import { failedTurnSettlement } from './workAgentTurnOutcome.mjs';
|
|
28
|
+
import { killGroup } from './processGroup.mjs';
|
|
29
|
+
import { createAgentBriefRunner } from './workAgentBriefRunner.mjs';
|
|
28
30
|
|
|
29
31
|
export function createWorkAgentTurns({
|
|
30
32
|
REJECT_RETRY_MS,
|
|
@@ -48,6 +50,7 @@ export function createWorkAgentTurns({
|
|
|
48
50
|
agentRemoteAt,
|
|
49
51
|
/** The turn's files, fetched into its worktree (workAttachments.mjs, 0.112.0). */
|
|
50
52
|
fetchAgentFiles,
|
|
53
|
+
makeBrief = createAgentBriefRunner({ admit, workChildren }),
|
|
51
54
|
}) {
|
|
52
55
|
const {
|
|
53
56
|
postAgentTurn,
|
|
@@ -91,8 +94,23 @@ export function createWorkAgentTurns({
|
|
|
91
94
|
* directory names, and those are places.
|
|
92
95
|
*/
|
|
93
96
|
const agentChildren = new Map(); // placeId -> child process
|
|
97
|
+
/**
|
|
98
|
+
* INTERRUPT NOW (2026-10-13) — each turn this lane has taken: its place and
|
|
99
|
+
* whether a person asked to stop it. A stop kills the CLI's PROCESS GROUP
|
|
100
|
+
* (it is spawned detached, so the commands it started go with it); a turn
|
|
101
|
+
* asked to stop before it spawned never spawns.
|
|
102
|
+
*/
|
|
103
|
+
const agentRuns = new Map(); // turnId -> { place, interrupted }
|
|
104
|
+
/** Turns answered `ran: false` here, so a roster read made before that
|
|
105
|
+
* settle landed is not answered again at once. */
|
|
106
|
+
const notRunningSaid = new Map(); // turnId -> when
|
|
107
|
+
/** Agents whose run this lane just stopped: the next turn is the one sent to
|
|
108
|
+
* interrupt it, and is told so even when the roster that handed it over was
|
|
109
|
+
* read before the stop's settle landed. Taken once, for a little while. */
|
|
110
|
+
const stoppedFor = new Map(); // agentId -> when
|
|
94
111
|
|
|
95
112
|
const { runAgentTurnInPlace, commitsBetween } = createAgentTurnExecution({
|
|
113
|
+
makeBrief,
|
|
96
114
|
baseRef,
|
|
97
115
|
baseDir,
|
|
98
116
|
repoRoot,
|
|
@@ -172,12 +190,18 @@ export function createWorkAgentTurns({
|
|
|
172
190
|
// A WRITER on the agent's own place — see the lane header. Only `a-<id>`
|
|
173
191
|
// places: those are agents' by construction, and anything else here would
|
|
174
192
|
// be a tab's directory, where a turn is a reader by the product's own law.
|
|
193
|
+
const run = agentRuns.get(turnId) ?? { place, interrupted: false };
|
|
175
194
|
await inPlace(place, place.startsWith('a-'), async () => {
|
|
176
195
|
try {
|
|
177
|
-
await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured });
|
|
196
|
+
await runAgentTurnInPlace(job, { turnId, agentId, place, releaseSlot, postAgentTurn: settleOnce, measured, run });
|
|
197
|
+
if (run.interrupted && measured.ranCli) stoppedFor.set(agentId, Date.now());
|
|
178
198
|
} catch (error) {
|
|
179
199
|
if (settled) return;
|
|
180
200
|
const commits = measured.wt ? commitsBetween(measured.wt, measured.before) : [];
|
|
201
|
+
if (run.interrupted) {
|
|
202
|
+
await postAgentTurn({ turnId, outcome: 'nothing', interrupted: true, ran: true, commits, ...(measured.usage ? { usage: measured.usage } : {}) });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
181
205
|
await postAgentTurn(
|
|
182
206
|
failedTurnSettlement({
|
|
183
207
|
turnId,
|
|
@@ -235,7 +259,7 @@ export function createWorkAgentTurns({
|
|
|
235
259
|
}
|
|
236
260
|
};
|
|
237
261
|
|
|
238
|
-
const processAgentTurnJobs = (jobs) => {
|
|
262
|
+
const processAgentTurnJobs = (jobs, { reportsOnly = false } = {}) => {
|
|
239
263
|
const list = Array.isArray(jobs) ? jobs : [];
|
|
240
264
|
pruneHeldReports(list);
|
|
241
265
|
/** One deferral line per tick, however many turns were offered. */
|
|
@@ -244,6 +268,7 @@ export function createWorkAgentTurns({
|
|
|
244
268
|
const id = String(job?.id || '');
|
|
245
269
|
if (!id || agentTurns.has(id)) continue;
|
|
246
270
|
const held = agentReported.get(id);
|
|
271
|
+
if (reportsOnly && !held) continue;
|
|
247
272
|
// A body the server REFUSED waits out its backoff — still held, so the
|
|
248
273
|
// CLI is never re-run in the meantime.
|
|
249
274
|
if (held && (agentRejectedUntil.get(id) ?? 0) > Date.now()) continue;
|
|
@@ -306,11 +331,16 @@ export function createWorkAgentTurns({
|
|
|
306
331
|
}
|
|
307
332
|
const releaseSlot = admit.reserve();
|
|
308
333
|
agentTurns.add(id);
|
|
309
|
-
|
|
334
|
+
agentRuns.set(id, { place: String(job.placeId), interrupted: false });
|
|
335
|
+
const stopped = stoppedFor.get(String(job.agentId));
|
|
336
|
+
stoppedFor.delete(String(job.agentId));
|
|
337
|
+
const run = stopped != null && Date.now() - stopped < 120_000 ? { ...job, interrupted: true } : job;
|
|
338
|
+
void runAgentTurn(run, releaseSlot).finally(() => {
|
|
310
339
|
// Belt for every path that never reached a spawn — the release is
|
|
311
340
|
// idempotent, so the normal case releases twice.
|
|
312
341
|
releaseSlot();
|
|
313
342
|
agentTurns.delete(id);
|
|
343
|
+
agentRuns.delete(id);
|
|
314
344
|
});
|
|
315
345
|
}
|
|
316
346
|
};
|
|
@@ -347,5 +377,31 @@ export function createWorkAgentTurns({
|
|
|
347
377
|
await Promise.allSettled(posts);
|
|
348
378
|
};
|
|
349
379
|
|
|
350
|
-
|
|
380
|
+
/**
|
|
381
|
+
* THE AGENT RUNS A PERSON ASKED TO STOP (`roster.interruptAgentTurns`, read
|
|
382
|
+
* before the jobs). One this lane is running is stopped — the CLI's group
|
|
383
|
+
* killed — and reports `interrupted` once its CLI is gone. One that finished
|
|
384
|
+
* here keeps its own settle. One this lane is not running is answered `ran:
|
|
385
|
+
* false`, and the server puts it back in the queue untouched.
|
|
386
|
+
*/
|
|
387
|
+
const interruptAgentTurns = (ids) => {
|
|
388
|
+
const at = Date.now();
|
|
389
|
+
for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
|
|
390
|
+
for (const id of Array.isArray(ids) ? ids : []) {
|
|
391
|
+
if (typeof id !== 'string' || !id) continue;
|
|
392
|
+
const run = agentRuns.get(id);
|
|
393
|
+
if (run) {
|
|
394
|
+
if (!run.interrupted) note(`${c.cyan('agent')} ${c.dim('— interrupting the running turn')}`);
|
|
395
|
+
run.interrupted = true;
|
|
396
|
+
const child = agentChildren.get(run.place);
|
|
397
|
+
if (child) killGroup(child);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (agentReported.has(id) || notRunningSaid.has(id)) continue;
|
|
401
|
+
notRunningSaid.set(id, at);
|
|
402
|
+
void postAgentTurn({ turnId: id, outcome: 'nothing', interrupted: true, ran: false }).catch(() => {});
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported };
|
|
351
407
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN IDLE AGENT'S DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11).
|
|
3
|
+
*
|
|
4
|
+
* A local discard reached the server only when a turn settled, so an agent
|
|
5
|
+
* waiting in Review kept every stored file a daemon had not relayed removing:
|
|
6
|
+
* the marketing imagery card showed nine images while its worktree held the
|
|
7
|
+
* two the person kept, and nothing would change until somebody sent another
|
|
8
|
+
* message. So each agent worktree this machine holds is measured against its
|
|
9
|
+
* stored inventory once per process, under the place's writer lock and never
|
|
10
|
+
* beside a turn — the reporter's own evidence rule (artifactIteration.mjs)
|
|
11
|
+
* decides what is a discard. Only agents: a tab's next turn is the natural
|
|
12
|
+
* moment to reconcile a conversation, and a delivery in Review has none.
|
|
13
|
+
*
|
|
14
|
+
* A failure is said in the log and tried again on a later beat, a bounded
|
|
15
|
+
* number of times; the stored files stay listed, which is the truth.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
/** Attempts per agent per process, and the wait between two of them. */
|
|
20
|
+
const MAX_TRIES = 4;
|
|
21
|
+
const RETRY_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
export function createWorkArtifactReconcile({
|
|
24
|
+
artifacts,
|
|
25
|
+
placeLocks,
|
|
26
|
+
inPlace,
|
|
27
|
+
placeDir,
|
|
28
|
+
beforeArtifacts,
|
|
29
|
+
getArtifactsAccepted,
|
|
30
|
+
log = () => {},
|
|
31
|
+
now = Date.now,
|
|
32
|
+
}) {
|
|
33
|
+
const done = new Set(); // agent places reconciled by this process
|
|
34
|
+
const tries = new Map(); // agent place -> { n, at }
|
|
35
|
+
let sweeping = null;
|
|
36
|
+
|
|
37
|
+
const reconcileOne = async (place) => {
|
|
38
|
+
const wt = placeDir(place);
|
|
39
|
+
if (!wt || !existsSync(wt)) return;
|
|
40
|
+
const agentId = place.slice(2);
|
|
41
|
+
try {
|
|
42
|
+
await inPlace(place, true, () => artifacts.reconcile({ placeDir: wt, before: beforeArtifacts(wt), agentId }));
|
|
43
|
+
done.add(place);
|
|
44
|
+
tries.delete(place);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const n = (tries.get(place)?.n ?? 0) + 1;
|
|
47
|
+
tries.set(place, { n, at: now() });
|
|
48
|
+
log(`agent ${agentId}: artifact cleanup incomplete — ${error?.message ?? error}` +
|
|
49
|
+
(n >= MAX_TRIES ? ' (not retried again until the daemon restarts)' : ''));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The roster's live places; agent ones are `a-<agentId>`. Never awaited by the poll. */
|
|
54
|
+
const reconcileIdleArtifacts = (activeIds) => {
|
|
55
|
+
if (!Array.isArray(activeIds) || !getArtifactsAccepted()) return null;
|
|
56
|
+
if (sweeping) return sweeping;
|
|
57
|
+
const live = new Set(activeIds);
|
|
58
|
+
for (const id of done) if (!live.has(id)) done.delete(id);
|
|
59
|
+
for (const id of tries.keys()) if (!live.has(id)) tries.delete(id);
|
|
60
|
+
const due = activeIds.filter((place) => {
|
|
61
|
+
if (typeof place !== 'string' || !place.startsWith('a-') || place.length <= 2) return false;
|
|
62
|
+
if (done.has(place) || placeLocks.has(place)) return false; // a turn or a merge is there
|
|
63
|
+
const t = tries.get(place);
|
|
64
|
+
return !t || (t.n < MAX_TRIES && now() - t.at >= RETRY_MS);
|
|
65
|
+
});
|
|
66
|
+
if (due.length === 0) return null;
|
|
67
|
+
// One place at a time: each is an inventory read and its removals.
|
|
68
|
+
sweeping = (async () => {
|
|
69
|
+
try {
|
|
70
|
+
for (const place of due) await reconcileOne(place);
|
|
71
|
+
} finally {
|
|
72
|
+
sweeping = null;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
return sweeping;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return { reconcileIdleArtifacts };
|
|
79
|
+
}
|
|
@@ -60,6 +60,7 @@ export async function runCappedTurn({
|
|
|
60
60
|
onSpawn = () => {},
|
|
61
61
|
onInit = () => {},
|
|
62
62
|
removeTranscript = removeTranscriptSoon,
|
|
63
|
+
signal,
|
|
63
64
|
}) {
|
|
64
65
|
// Wiring mistakes, refused before anything spawns: a hook inside turnArgs
|
|
65
66
|
// would be silently replaced below, and a missing cap would fire at once.
|
|
@@ -74,7 +75,14 @@ export async function runCappedTurn({
|
|
|
74
75
|
const capped = new Promise((resolve) => {
|
|
75
76
|
stopWaiting = resolve;
|
|
76
77
|
});
|
|
78
|
+
const cancel = () => {
|
|
79
|
+
wedged = true;
|
|
80
|
+
try { child?.kill('SIGKILL'); } catch { /* already gone */ }
|
|
81
|
+
stopWaiting('');
|
|
82
|
+
};
|
|
83
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
77
84
|
try {
|
|
85
|
+
if (signal?.aborted) { cancel(); return { out: '', wedged, spawned: false, sessionId }; }
|
|
78
86
|
const turn = runTurn({
|
|
79
87
|
...turnArgs,
|
|
80
88
|
onInit: (i) => {
|
|
@@ -96,11 +104,13 @@ export async function runCappedTurn({
|
|
|
96
104
|
}, capMs);
|
|
97
105
|
timer.unref?.();
|
|
98
106
|
onSpawn(ch);
|
|
107
|
+
if (signal?.aborted) cancel();
|
|
99
108
|
},
|
|
100
109
|
});
|
|
101
110
|
const out = await Promise.race([turn, capped]);
|
|
102
111
|
return { out, wedged, spawned: child !== null, sessionId };
|
|
103
112
|
} finally {
|
|
113
|
+
signal?.removeEventListener('abort', cancel);
|
|
104
114
|
if (timer) clearTimeout(timer);
|
|
105
115
|
if (child) workChildren.delete(child);
|
|
106
116
|
releaseSlot();
|
|
@@ -64,7 +64,14 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
|
64
64
|
// used to cut the command at 2000 before it reached this scrub, so a
|
|
65
65
|
// secret straddling that character kept its prefix. They now hand a
|
|
66
66
|
// bounded window, and the server's cap is applied here, last.
|
|
67
|
-
auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString()
|
|
67
|
+
auditBatch.push({ command: scrubCut(a.command, MAX_COMMAND_CHARS), at: new Date().toISOString(),
|
|
68
|
+
...(a.observation ? { observation: {
|
|
69
|
+
...(a.observation.id ? { id: scrubCut(a.observation.id, 160) } : {}),
|
|
70
|
+
outcome: a.observation.outcome ?? 'unknown',
|
|
71
|
+
...(Number.isSafeInteger(a.observation.exitCode) ? { exitCode: a.observation.exitCode } : {}),
|
|
72
|
+
...(a.observation.errorCategory ? { errorCategory: a.observation.errorCategory } : {}),
|
|
73
|
+
} } : {}),
|
|
74
|
+
});
|
|
68
75
|
if (auditBatch.length >= 25) flushAudit();
|
|
69
76
|
};
|
|
70
77
|
return { flushAudit, auditCommand };
|
package/bin/lib/workPlaces.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* the session lane's turn intake both go through it (audit 2026-10-10, F141),
|
|
13
13
|
* so a lane that learns a place cannot store one unvalidated.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from 'node:fs';
|
|
15
|
+
import { existsSync, statSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
|
|
18
18
|
|
|
@@ -290,5 +290,21 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
290
290
|
}
|
|
291
291
|
};
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
/**
|
|
294
|
+
* WHEN `git worktree add` CUT THIS WORKTREE: its private git dir's
|
|
295
|
+
* `commondir`, written once at creation and left alone by commits, lock,
|
|
296
|
+
* repair and move. A recreated worktree is a new birth. Null for the
|
|
297
|
+
* checkout (no such file) and for anything unreadable — read by the
|
|
298
|
+
* artifact reporter, which then has no evidence a place held older rows.
|
|
299
|
+
*/
|
|
300
|
+
const worktreeBornAt = (wt) => {
|
|
301
|
+
const path = sessionMetaPath(wt, 'commondir');
|
|
302
|
+
try {
|
|
303
|
+
return path ? statSync(path).mtimeMs : null;
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt };
|
|
294
310
|
}
|