flowviant 0.116.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 +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- 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 +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -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
|
+
}
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* serves both, so the two lanes cannot disagree about what a safe name is.
|
|
10
10
|
* The name rule itself is safeFileName.mjs's.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
14
15
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
15
16
|
import { excludeInWorktree } from './git.mjs';
|
|
16
17
|
import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
|
|
@@ -59,13 +60,60 @@ export function createWorkAttachments() {
|
|
|
59
60
|
// and the two sides never disagree about the cut. An empty name is
|
|
60
61
|
// `attachment`, the server's own fallback.
|
|
61
62
|
const safeUploadName = (raw) => safeFileName(raw, 'attachment');
|
|
62
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* NO LINK UNDER `.flowviant/` IS FOLLOWED, AND NOTHING THERE IS REPLACED
|
|
65
|
+
* (2026-10-10, the audit's F080). This lane writes a MEMBER'S bytes under a
|
|
66
|
+
* member's name, and it was the one `.flowviant/` writer with no guard:
|
|
67
|
+
* `mkdirSync` and `writeFileSync` went through a committed `.flowviant ->
|
|
68
|
+
* ~/.ssh` or `.flowviant/uploads -> ../../../.ssh`, and `existsSync` answered
|
|
69
|
+
* "absent" for a dangling link at the file's own name, which the write then
|
|
70
|
+
* followed to create its target — an attachment named `authorized_keys`
|
|
71
|
+
* landing in the operator's `~/.ssh`. Now: both directories must be real
|
|
72
|
+
* (ownedDirs.mjs `ensureRealSubdir`, the artifacts directory's rule — refused,
|
|
73
|
+
* never replaced), every look at a name is an `lstat` or an `O_NOFOLLOW`
|
|
74
|
+
* open, and each file is CREATED (`O_EXCL`): anything already at the name,
|
|
75
|
+
* a dangling link included, is never written through.
|
|
76
|
+
*/
|
|
77
|
+
const NEW_FILE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0);
|
|
78
|
+
/** Anything at all at `path` — a dangling link too. */
|
|
79
|
+
const taken = (path) => {
|
|
80
|
+
try {
|
|
81
|
+
lstatSync(path);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return e?.code !== 'ENOENT';
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/** The REGULAR file at `path` holds exactly `buf` — read only when the sizes
|
|
88
|
+
* agree, and never through a link. Non-blocking, so a fifo at the name is
|
|
89
|
+
* answered "no" rather than waited on. */
|
|
63
90
|
const holds = (path, buf) => {
|
|
91
|
+
let fd;
|
|
64
92
|
try {
|
|
65
|
-
|
|
93
|
+
const at = lstatSync(path);
|
|
94
|
+
if (!at.isFile() || at.size !== buf.byteLength) return false;
|
|
95
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
96
|
+
const st = fstatSync(fd);
|
|
97
|
+
return st.isFile() && st.size === buf.byteLength && readFileSync(fd).equals(buf);
|
|
66
98
|
} catch {
|
|
67
99
|
return false;
|
|
100
|
+
} finally {
|
|
101
|
+
if (fd !== undefined) closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** A NEW regular file at `path` holding `buf`, at the mode `writeFileSync`
|
|
105
|
+
* used. Throws (EEXIST) when anything is already there; a write that fails
|
|
106
|
+
* part-way takes its own half-file with it. */
|
|
107
|
+
const createFile = (path, buf) => {
|
|
108
|
+
const fd = openSync(path, NEW_FILE, 0o666);
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(fd, buf);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
rmSync(path, { force: true });
|
|
114
|
+
throw e;
|
|
68
115
|
}
|
|
116
|
+
closeSync(fd);
|
|
69
117
|
};
|
|
70
118
|
/**
|
|
71
119
|
* THE ONE DOWNLOAD LOOP. Each entry's relative path, or null where it did
|
|
@@ -81,11 +129,10 @@ export function createWorkAttachments() {
|
|
|
81
129
|
*/
|
|
82
130
|
const landFiles = async (wt, list, endpoint, sameBytesKept) => {
|
|
83
131
|
const dir = join(wt, UPLOAD_DIR);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
132
|
+
// A `.flowviant` or `uploads` that is a link or not a directory refuses
|
|
133
|
+
// the whole batch before anything is fetched: every file is a miss the
|
|
134
|
+
// turn is told about, and the repository's content is left as it is.
|
|
135
|
+
if (!ensureRealSubdir(wt, UPLOAD_DIR)) return list.map(() => null);
|
|
89
136
|
// "Never committed by us" has to be true for GIT, not just for this code:
|
|
90
137
|
// an untracked `.flowviant/` makes the whole worktree dirty, which refuses
|
|
91
138
|
// every ship, exempts the tree from closed-tab retirement forever, and
|
|
@@ -115,14 +162,22 @@ export function createWorkAttachments() {
|
|
|
115
162
|
// silently overwriting one with the other loses a file the human sent.
|
|
116
163
|
let name = safeUploadName(a.name);
|
|
117
164
|
let already = sameBytesKept && holds(join(dir, name), buf);
|
|
118
|
-
if (!already &&
|
|
165
|
+
if (!already && taken(join(dir, name))) {
|
|
119
166
|
const dot = name.lastIndexOf('.');
|
|
120
167
|
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
121
168
|
const ext = dot > 0 ? name.slice(dot) : '';
|
|
122
169
|
name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
|
|
123
|
-
|
|
170
|
+
// The id-suffixed name is this file's own: holding these bytes, it
|
|
171
|
+
// IS this file, on either lane (it used to be rewritten with them).
|
|
172
|
+
// Anything else there is a miss — never replaced, never followed.
|
|
173
|
+
already = holds(join(dir, name), buf);
|
|
174
|
+
}
|
|
175
|
+
if (!already) {
|
|
176
|
+
// Looked at again right before the write: the batch's first check
|
|
177
|
+
// is up to a download old.
|
|
178
|
+
if (realDirState(join(wt, '.flowviant')) !== 'dir' || realDirState(dir) !== 'dir') continue;
|
|
179
|
+
createFile(join(dir, name), buf);
|
|
124
180
|
}
|
|
125
|
-
if (!already) writeFileSync(join(dir, name), buf);
|
|
126
181
|
landed[landed.length - 1] = `${UPLOAD_DIR}/${name}`;
|
|
127
182
|
} catch {
|
|
128
183
|
/* one file failing must not fail the turn — the prompt lists what
|
package/bin/lib/workBrain.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* — each importing it directly — and it is pure over the job and the runtime
|
|
8
8
|
* table, so its home is a module of its own rather than either lane.
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
10
|
+
import { runtimeById } from './runtimes.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The shape a per-tab model name must have before it rides argv as
|
|
@@ -15,8 +15,13 @@ import { RUNTIMES } from './runtimes.mjs';
|
|
|
15
15
|
* (below): it comes off the wire and lands in a child process's arguments —
|
|
16
16
|
* alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
|
|
17
17
|
* leading dash, which is an argv that parses as a flag.
|
|
18
|
+
*
|
|
19
|
+
* The server's route schemas copy it as `AGENT_MODEL_RE` (shared
|
|
20
|
+
* agent-runner.schema.ts), and the release parity gate holds the two equal
|
|
21
|
+
* (2026-10-10, audit F613): a server that accepts a name this drops has the
|
|
22
|
+
* board assert a pin the machine silently replaces with its default.
|
|
18
23
|
*/
|
|
19
|
-
const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
|
|
24
|
+
export const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
|
|
@@ -39,7 +44,7 @@ const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
|
|
|
39
44
|
* One global ladder once accepted `max` for every runtime, and Codex has no
|
|
40
45
|
* `max`: a skewed or previously queued Codex job reached argv as a
|
|
41
46
|
* `model_reasoning_effort` its CLI refuses. The per-CLI list lives beside each
|
|
42
|
-
* runtime's argv builder (`
|
|
47
|
+
* runtime's argv builder (`runtimeById(id).efforts`, runtime{Claude,Codex,Antigravity}.mjs); a runtime this
|
|
43
48
|
* daemon does not define runs nothing, so it spells no effort either.
|
|
44
49
|
*/
|
|
45
50
|
export function brainFor(job, runtimeId) {
|
|
@@ -47,7 +52,7 @@ export function brainFor(job, runtimeId) {
|
|
|
47
52
|
const model = typeof job?.model === 'string' ? job.model.trim() : '';
|
|
48
53
|
if (model && WORK_MODEL_RE.test(model)) out.model = model;
|
|
49
54
|
const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
|
|
50
|
-
const efforts =
|
|
55
|
+
const efforts = runtimeById(runtimeId)?.efforts ?? [];
|
|
51
56
|
if (effort && efforts.includes(effort)) out.effort = effort;
|
|
52
57
|
return out;
|
|
53
58
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE CAPPED READ-ONLY CONSULT TURN — the process plumbing the unattended
|
|
3
|
+
* one-shot lanes share: the Deploy-press planner (workAgentPlans.mjs), the AI
|
|
4
|
+
* pre-review (workAgentPrecheck.mjs) and intake (workIntake.mjs).
|
|
5
|
+
*
|
|
6
|
+
* ONE HOME (2026-10-10, the audit). Each lane carried its own forty-to-eighty
|
|
7
|
+
* lines of this, and the copies had already begun to drift. Every step below
|
|
8
|
+
* is a rule some incident taught, and a lane that copied the wrong one would
|
|
9
|
+
* show it only as a daemon that never auto-updates again:
|
|
10
|
+
* · THE CHILD IS REGISTERED IN `workChildren`, AND REMOVED IN A `finally`.
|
|
11
|
+
* It is what `workBusy()` counts and what counts against the machine's
|
|
12
|
+
* ceiling (liveTurnCount); a leaked entry keeps the daemon "busy" forever,
|
|
13
|
+
* which blocks every auto-update, silently, until a restart. No task id: a
|
|
14
|
+
* consult turn belongs to no card, and the snapshot's per-task rows must
|
|
15
|
+
* not invent one.
|
|
16
|
+
* · THE ADMISSION RESERVATION IS RELEASED AT THE SPAWN, and again in the
|
|
17
|
+
* `finally` for a turn that never spawned — release is idempotent, so
|
|
18
|
+
* doing both is the normal case (admission.mjs).
|
|
19
|
+
* · THE CAP IS ARMED AT THE SPAWN, not at entry: time spent waiting for a
|
|
20
|
+
* checkout lock is not a wedged CLI. It SIGKILLs the CHILD and never its
|
|
21
|
+
* group — the rule teardown keeps: a read-only turn starts no server, so
|
|
22
|
+
* there is nothing behind it worth signalling and everything to lose by
|
|
23
|
+
* signalling somebody else's.
|
|
24
|
+
* · THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
|
|
25
|
+
* the kill: a SIGKILLed process whose stdio a grandchild still holds can be
|
|
26
|
+
* slow to emit `close`, or never emit it, and the caller is holding a
|
|
27
|
+
* claimed job (or a place's writer lock) open behind this promise.
|
|
28
|
+
* · THE TURN'S TRANSCRIPT IS REMOVED ON EVERY EXIT — settled, wedged or
|
|
29
|
+
* thrown. `claude -p` writes `~/.claude/projects/<munged-cwd>/<id>.jsonl`,
|
|
30
|
+
* and the CLI's `--continue` is cwd-keyed: left in the checkout it displaces
|
|
31
|
+
* the operator's own conversation from the `+` adopt menu and is what their
|
|
32
|
+
* `claude --continue` resumes; left in an agent's worktree it is what the
|
|
33
|
+
* agent's next turn resumes, under build permissions. The id is the one the
|
|
34
|
+
* CLI reports at `system.init`, so the file is ours by name.
|
|
35
|
+
*
|
|
36
|
+
* WHAT STAYS WITH EACH LANE is policy, never plumbing: its lock, its prompt,
|
|
37
|
+
* profile and brain, its usage callback (set or accumulate is a per-lane
|
|
38
|
+
* choice), its cap length, its narration, and the words it settles with.
|
|
39
|
+
*/
|
|
40
|
+
import { removeTranscriptSoon } from './runtimeCapabilities.mjs';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run `runTurn(turnArgs)` under a `capMs` cap. Resolves
|
|
44
|
+
* `{ out, wedged, spawned, sessionId }`: `out` is the turn's answer, or `''`
|
|
45
|
+
* when the cap fired (`wedged`); `spawned` says a CLI process existed; the
|
|
46
|
+
* session id is the conversation the CLI named, or null. A turn that throws
|
|
47
|
+
* rethrows here, after the same cleanup.
|
|
48
|
+
*
|
|
49
|
+
* `onSpawn` and `onInit` are the caller's own hooks, run after this file's
|
|
50
|
+
* bookkeeping — which is why they ride beside `turnArgs` and never inside it.
|
|
51
|
+
* `removeTranscript` is a test's.
|
|
52
|
+
*/
|
|
53
|
+
export async function runCappedTurn({
|
|
54
|
+
runTurn,
|
|
55
|
+
turnArgs,
|
|
56
|
+
capMs,
|
|
57
|
+
workChildren,
|
|
58
|
+
releaseSlot = () => {},
|
|
59
|
+
transcriptCwd,
|
|
60
|
+
onSpawn = () => {},
|
|
61
|
+
onInit = () => {},
|
|
62
|
+
removeTranscript = removeTranscriptSoon,
|
|
63
|
+
signal,
|
|
64
|
+
}) {
|
|
65
|
+
// Wiring mistakes, refused before anything spawns: a hook inside turnArgs
|
|
66
|
+
// would be silently replaced below, and a missing cap would fire at once.
|
|
67
|
+
if (turnArgs && ('onSpawn' in turnArgs || 'onInit' in turnArgs))
|
|
68
|
+
throw new TypeError('runCappedTurn: pass onSpawn/onInit beside turnArgs, never inside it');
|
|
69
|
+
if (!(Number.isFinite(capMs) && capMs > 0)) throw new TypeError('runCappedTurn: capMs must be a positive number');
|
|
70
|
+
let child = null;
|
|
71
|
+
let timer = null;
|
|
72
|
+
let wedged = false;
|
|
73
|
+
let sessionId = null;
|
|
74
|
+
let stopWaiting = () => {};
|
|
75
|
+
const capped = new Promise((resolve) => {
|
|
76
|
+
stopWaiting = resolve;
|
|
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 });
|
|
84
|
+
try {
|
|
85
|
+
if (signal?.aborted) { cancel(); return { out: '', wedged, spawned: false, sessionId }; }
|
|
86
|
+
const turn = runTurn({
|
|
87
|
+
...turnArgs,
|
|
88
|
+
onInit: (i) => {
|
|
89
|
+
if (typeof i?.sessionId === 'string' && i.sessionId.trim()) sessionId = i.sessionId.trim();
|
|
90
|
+
onInit(i);
|
|
91
|
+
},
|
|
92
|
+
onSpawn: (ch) => {
|
|
93
|
+
child = ch;
|
|
94
|
+
workChildren.set(ch, null);
|
|
95
|
+
releaseSlot();
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
wedged = true;
|
|
98
|
+
try {
|
|
99
|
+
ch.kill('SIGKILL');
|
|
100
|
+
} catch {
|
|
101
|
+
/* already gone */
|
|
102
|
+
}
|
|
103
|
+
stopWaiting('');
|
|
104
|
+
}, capMs);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
onSpawn(ch);
|
|
107
|
+
if (signal?.aborted) cancel();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const out = await Promise.race([turn, capped]);
|
|
111
|
+
return { out, wedged, spawned: child !== null, sessionId };
|
|
112
|
+
} finally {
|
|
113
|
+
signal?.removeEventListener('abort', cancel);
|
|
114
|
+
if (timer) clearTimeout(timer);
|
|
115
|
+
if (child) workChildren.delete(child);
|
|
116
|
+
releaseSlot();
|
|
117
|
+
if (sessionId) removeTranscript(transcriptCwd, sessionId);
|
|
118
|
+
}
|
|
119
|
+
}
|
package/bin/lib/workChildren.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* on this box" belong together — and apart from any one lane.
|
|
11
11
|
*/
|
|
12
12
|
import { createAdmission } from './admission.mjs';
|
|
13
|
+
import { killGroup } from './processGroup.mjs';
|
|
13
14
|
|
|
14
15
|
export function createWorkChildren({ extraLiveTurns }) {
|
|
15
16
|
/**
|
|
@@ -54,11 +55,10 @@ export function createWorkChildren({ extraLiveTurns }) {
|
|
|
54
55
|
const groupKillChildren = new Set();
|
|
55
56
|
const shutdownWork = () => {
|
|
56
57
|
for (const [ch] of workChildren) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// A group that has already gone, or a pid that is no longer a leader.
|
|
58
|
+
// A group that has already gone, or a pid that is no longer a leader,
|
|
59
|
+
// falls back to the child (processGroup.mjs).
|
|
60
|
+
if (groupKillChildren.has(ch)) killGroup(ch, 'SIGTERM');
|
|
61
|
+
else {
|
|
62
62
|
try {
|
|
63
63
|
ch.kill('SIGTERM');
|
|
64
64
|
} catch {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* blocking the turn.
|
|
9
9
|
*/
|
|
10
10
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { scrubCut } from './uplinkScrub.mjs';
|
|
12
12
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
13
13
|
|
|
14
14
|
// THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
|
|
@@ -21,6 +21,10 @@ import { fleetEndpoint } from './fleetWire.mjs';
|
|
|
21
21
|
// a kill, and again at settle. Best-effort: a failed post drops the
|
|
22
22
|
// batch rather than blocking the turn — the surface says it is the
|
|
23
23
|
// machine's report, not a syscall trace.
|
|
24
|
+
/** The server's own cap on one audited command (sessionCommands.ts
|
|
25
|
+
* `MAX_COMMAND_CHARS`), applied here AFTER the scrub. */
|
|
26
|
+
const MAX_COMMAND_CHARS = 2000;
|
|
27
|
+
|
|
24
28
|
export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
25
29
|
const SESSION_COMMANDS_URL = fleetEndpoint('session-commands', FLEET_URL);
|
|
26
30
|
const auditBatch = [];
|
|
@@ -55,7 +59,19 @@ export function createCommandAudit({ sessionId, turnId, runtime, cwd }) {
|
|
|
55
59
|
// `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
|
|
56
60
|
// in the admin view, so the one uplink that omitted scrub was the
|
|
57
61
|
// one most likely to carry a plaintext secret.
|
|
58
|
-
|
|
62
|
+
//
|
|
63
|
+
// SCRUBBED WHOLE, THEN CUT (2026-10-10, the audit's F128): the parsers
|
|
64
|
+
// used to cut the command at 2000 before it reached this scrub, so a
|
|
65
|
+
// secret straddling that character kept its prefix. They now hand a
|
|
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(),
|
|
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
|
+
});
|
|
59
75
|
if (auditBatch.length >= 25) flushAudit();
|
|
60
76
|
};
|
|
61
77
|
return { flushAudit, auditCommand };
|
package/bin/lib/workDiffs.mjs
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
/** Commit diff requests and their bounded reports. */
|
|
2
2
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
3
|
-
import { git } from './git.mjs';
|
|
3
|
+
import { git, GIT_WIDE_BUFFER, OBJECT_ID_RE } from './git.mjs';
|
|
4
4
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
5
|
+
import { scrub, scrubCut } from './uplinkScrub.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A PATCH BIGGER THAN NODE'S 1 MiB DEFAULT BUFFER (2026-10-10, the audit's
|
|
9
|
+
* F150). `git()` (git.mjs) defaults to Node's 1 MiB `maxBuffer`, so any
|
|
10
|
+
* commit whose patch passed 1 MiB failed `ENOBUFS` and the viewer was told it
|
|
11
|
+
* errored, when it should have been shown the first 256 KB marked truncated.
|
|
12
|
+
* The same call with room for GIT_WIDE_BUFFER (8 MiB); past that, what git
|
|
13
|
+
* printed up to the bound is still a patch to truncate, never an error.
|
|
14
|
+
*/
|
|
15
|
+
function gitBig(args, cwd) {
|
|
16
|
+
try {
|
|
17
|
+
return git(args, cwd, { maxBuffer: GIT_WIDE_BUFFER });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (e?.code === 'ENOBUFS' && typeof e.stdout === 'string' && e.stdout) return e.stdout;
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
5
23
|
|
|
6
24
|
export function createWorkDiffs({ repoRoot }) {
|
|
7
25
|
const DIFF_DONE_URL = fleetEndpoint('diff-done', FLEET_URL);
|
|
@@ -23,6 +41,18 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
23
41
|
* machine" is a real answer, and a viewer spinning forever is the worst thing
|
|
24
42
|
* this can do. The one thing never sent is a guess — an empty patch would
|
|
25
43
|
* tell a reader the commit changed nothing.
|
|
44
|
+
*
|
|
45
|
+
* ONLY A COMMIT, AND SCRUBBED (2026-10-10, the audit's F150). The sha is a
|
|
46
|
+
* server-named hex string, and `git show <sha>` prints whatever object it
|
|
47
|
+
* names — a blob, a tree, a tag — so it is first resolved as
|
|
48
|
+
* `<sha>^{commit}` and anything else is answered "no such commit on this
|
|
49
|
+
* machine" with no content. (The hex check below already keeps it from
|
|
50
|
+
* reading as an option, so no `--end-of-options`, which git before 2.30
|
|
51
|
+
* does not take in `rev-parse`.) No reachability check: the run page asks
|
|
52
|
+
* for unpushed agent commits on load, and a remote-branch bound would refuse
|
|
53
|
+
* them. And the patch is the one repository uplink that skipped `scrub`; it
|
|
54
|
+
* now passes through it WHOLE, before the line-boundary cut — the
|
|
55
|
+
* scrub-then-cut order every other lane keeps.
|
|
26
56
|
*/
|
|
27
57
|
const MAX_PATCH_BYTES = 256 * 1024;
|
|
28
58
|
const servedDiffs = new Set();
|
|
@@ -48,7 +78,7 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
48
78
|
const sha = String(job?.sha || '').toLowerCase();
|
|
49
79
|
// A sha becomes a `git` argument. The server validates too; one place
|
|
50
80
|
// doing this check is one deploy away from being zero places.
|
|
51
|
-
if (
|
|
81
|
+
if (!OBJECT_ID_RE.test(sha)) continue;
|
|
52
82
|
// In-flight guard, not a cache: the server stops offering a sha the
|
|
53
83
|
// moment it settles, so this only stops the SAME poll's job being
|
|
54
84
|
// started twice while its `git show` is still running.
|
|
@@ -56,6 +86,22 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
56
86
|
servedDiffs.add(sha);
|
|
57
87
|
void (async () => {
|
|
58
88
|
try {
|
|
89
|
+
let commit = '';
|
|
90
|
+
try {
|
|
91
|
+
commit = gitBig(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], repoRoot);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
// Exit 1 is `--verify --quiet`'s "not a commit here" (absent,
|
|
94
|
+
// ambiguous, or another object type); anything else is git's own
|
|
95
|
+
// failure, relayed in its words as before.
|
|
96
|
+
if (e?.status !== 1) {
|
|
97
|
+
await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!/^[0-9a-f]{40,64}$/.test(commit)) {
|
|
102
|
+
await postDiff({ sha, error: 'no such commit on this machine' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
59
105
|
let patch = '';
|
|
60
106
|
const files = [];
|
|
61
107
|
try {
|
|
@@ -63,13 +109,13 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
63
109
|
// date already reached the card on the worktree sweep, and
|
|
64
110
|
// repeating them inside the patch would put a second copy above
|
|
65
111
|
// every hunk.
|
|
66
|
-
patch =
|
|
112
|
+
patch = scrub(gitBig(['show', '--patch', '--format=', commit], repoRoot));
|
|
67
113
|
} catch (e) {
|
|
68
|
-
await postDiff({ sha, error:
|
|
114
|
+
await postDiff({ sha, error: scrubCut(e?.message || e, 500) });
|
|
69
115
|
return;
|
|
70
116
|
}
|
|
71
117
|
try {
|
|
72
|
-
const raw =
|
|
118
|
+
const raw = gitBig(['show', '--numstat', '--format=', commit], repoRoot);
|
|
73
119
|
for (const line of raw.split('\n')) {
|
|
74
120
|
if (!line.trim()) continue;
|
|
75
121
|
const [a, d, ...rest] = line.split('\t');
|
|
@@ -77,7 +123,7 @@ export function createWorkDiffs({ repoRoot }) {
|
|
|
77
123
|
if (!path) continue;
|
|
78
124
|
const binary = a === '-' || d === '-';
|
|
79
125
|
files.push({
|
|
80
|
-
path: path
|
|
126
|
+
path: scrubCut(path, 300),
|
|
81
127
|
added: binary ? 0 : Number(a) || 0,
|
|
82
128
|
deleted: binary ? 0 : Number(d) || 0,
|
|
83
129
|
...(binary ? { binary: true } : {}),
|
package/bin/lib/workIntake.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** One leased incoming ticket, drafted by this machine's read-only CLI. */
|
|
2
|
-
import { FLEET_URL,
|
|
2
|
+
import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
|
|
3
3
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
4
|
+
import { claimFleetLease } from './fleetPost.mjs';
|
|
4
5
|
import { runTurn as defaultRunTurn } from './runTurn.mjs';
|
|
5
6
|
import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
|
|
6
|
-
import {
|
|
7
|
+
import { runCappedTurn } from './workCappedTurn.mjs';
|
|
7
8
|
import { REPO_PLACE } from './workPlaces.mjs';
|
|
8
9
|
import { SYSTEM_INTAKE, INTAKE_KICKOFF } from './prompts.mjs';
|
|
9
10
|
import { parseIntakeDraft } from './intakeDraft.mjs';
|
|
@@ -31,25 +32,12 @@ export function createWorkIntake({
|
|
|
31
32
|
const done = fleetEndpoint(INTAKE_DONE_ENDPOINT, FLEET_URL);
|
|
32
33
|
const intake = new Set();
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'User-Agent': USER_AGENT,
|
|
41
|
-
'Content-Type': 'application/json',
|
|
42
|
-
},
|
|
43
|
-
signal: AbortSignal.timeout(15_000),
|
|
44
|
-
body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
|
|
45
|
-
});
|
|
46
|
-
// A 409 means another holder (or a dismissed/landed ticket). No CLI and
|
|
47
|
-
// no settle: this machine never acquired the right to answer for it.
|
|
48
|
-
return res.status === 200 && (await res.json().catch(() => null))?.ok === true;
|
|
49
|
-
} catch {
|
|
50
|
-
return false;
|
|
51
|
-
}
|
|
52
|
-
};
|
|
35
|
+
// The intake route answers `{ ok: true }` on a 200, not `data.claimed`
|
|
36
|
+
// (machineIntake.routes.ts) — the same lease rule with its own winner test.
|
|
37
|
+
// A 409 means another holder (or a dismissed/landed ticket). No CLI and no
|
|
38
|
+
// settle: this machine never acquired the right to answer for it.
|
|
39
|
+
const claim = (id) =>
|
|
40
|
+
claimFleetLease(claiming, { id }, { won: (r) => r.status === 200 && r.json?.ok === true });
|
|
53
41
|
|
|
54
42
|
const report = async (body) => {
|
|
55
43
|
await postBestEffort(done, { ...body, instance: DAEMON_INSTANCE });
|
|
@@ -68,56 +56,43 @@ export function createWorkIntake({
|
|
|
68
56
|
}
|
|
69
57
|
|
|
70
58
|
let out = '';
|
|
71
|
-
let child = null;
|
|
72
|
-
let session = null;
|
|
73
|
-
let timer = null;
|
|
74
59
|
let wedged = false;
|
|
60
|
+
let spawned = false;
|
|
75
61
|
let usage = null;
|
|
76
62
|
// A spawned CLI can fail before reporting counters. Null records that the
|
|
77
63
|
// spend is unmeasured; four zeroes would falsely say it spent nothing.
|
|
78
|
-
const spent = () =>
|
|
64
|
+
const spent = () => spawned ? { usage } : {};
|
|
79
65
|
try {
|
|
80
66
|
await inPlace(REPO_PLACE, false, async () => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
releaseSlot();
|
|
102
|
-
// Start the cap at spawn, never while a checkout writer holds the
|
|
103
|
-
// reader lock; SIGKILL and resolve even if a grandchild holds stdio.
|
|
104
|
-
timer = setTimeout(() => {
|
|
105
|
-
wedged = true;
|
|
106
|
-
try { ch.kill('SIGKILL'); } catch { /* already gone */ }
|
|
107
|
-
stopWaiting('');
|
|
108
|
-
}, 10 * 60_000);
|
|
109
|
-
timer.unref?.();
|
|
67
|
+
// One capped consult turn (workCappedTurn.mjs): the cap starts at
|
|
68
|
+
// spawn, never while a checkout writer holds the reader lock, and
|
|
69
|
+
// SIGKILLs the child and resolves even if a grandchild holds stdio.
|
|
70
|
+
({ out, wedged } = await runCappedTurn({
|
|
71
|
+
runTurn,
|
|
72
|
+
capMs: 10 * 60_000,
|
|
73
|
+
workChildren,
|
|
74
|
+
releaseSlot,
|
|
75
|
+
transcriptCwd: repoRoot,
|
|
76
|
+
turnArgs: {
|
|
77
|
+
prompt: INTAKE_KICKOFF(job),
|
|
78
|
+
system: SYSTEM_INTAKE,
|
|
79
|
+
profile: 'consult',
|
|
80
|
+
cwd: repoRoot,
|
|
81
|
+
runtime,
|
|
82
|
+
answerFromResult: true,
|
|
83
|
+
label: c.cyan('[intake]'),
|
|
84
|
+
// There is no MCP principal for a ticket, and no model or effort pin:
|
|
85
|
+
// this one-shot reader uses the machine's consult defaults.
|
|
86
|
+
onUsage: (u) => { usage = addUsage(usage, { ...u, runtime }); },
|
|
110
87
|
},
|
|
111
|
-
|
|
112
|
-
|
|
88
|
+
// Marked by the lane itself, so a turn that throws after its spawn
|
|
89
|
+
// still reports what it measured.
|
|
90
|
+
onSpawn: () => { spawned = true; },
|
|
91
|
+
}));
|
|
113
92
|
});
|
|
114
93
|
} catch (e) {
|
|
115
94
|
await report({ id, outcome: INTAKE_OUTCOMES.nothing, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
|
|
116
95
|
return;
|
|
117
|
-
} finally {
|
|
118
|
-
if (timer) clearTimeout(timer);
|
|
119
|
-
if (child) workChildren.delete(child);
|
|
120
|
-
if (session) setTimeout(() => removeProbeTranscript(repoRoot, session), 750).unref?.();
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
if (wedged) {
|