@onepointfour-packs/flaredeck-agent 0.2.37 → 0.2.39
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/dist/agent.d.ts +42 -3
- package/dist/agent.js +84 -6
- package/dist/agent.js.map +1 -1
- package/dist/automode.js +43 -3
- package/dist/automode.js.map +1 -1
- package/dist/boards.d.ts +72 -0
- package/dist/boards.js +136 -0
- package/dist/boards.js.map +1 -0
- package/dist/branches.d.ts +12 -1
- package/dist/branches.js +13 -3
- package/dist/branches.js.map +1 -1
- package/dist/browser.d.ts +59 -0
- package/dist/browser.js +260 -26
- package/dist/browser.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.js +37 -2
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +217 -0
- package/dist/credentials.js +332 -0
- package/dist/credentials.js.map +1 -0
- package/dist/engine-antigravity.js +2 -0
- package/dist/engine-antigravity.js.map +1 -1
- package/dist/engine-claude.js +14 -2
- package/dist/engine-claude.js.map +1 -1
- package/dist/engine.d.ts +19 -0
- package/dist/engine.js.map +1 -1
- package/dist/environment.d.ts +15 -3
- package/dist/environment.js +79 -9
- package/dist/environment.js.map +1 -1
- package/dist/envtools.d.ts +58 -0
- package/dist/envtools.js +151 -0
- package/dist/envtools.js.map +1 -0
- package/dist/flaredeck-proxy.d.ts +11 -1
- package/dist/flaredeck-proxy.js +27 -1
- package/dist/flaredeck-proxy.js.map +1 -1
- package/dist/flaredeck.d.ts +24 -0
- package/dist/flaredeck.js +20 -0
- package/dist/flaredeck.js.map +1 -1
- package/dist/github.d.ts +2 -0
- package/dist/github.js +4 -0
- package/dist/github.js.map +1 -1
- package/dist/githubauth.d.ts +11 -1
- package/dist/githubauth.js +82 -16
- package/dist/githubauth.js.map +1 -1
- package/dist/hydrate.d.ts +76 -0
- package/dist/hydrate.js +77 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/limits.d.ts +184 -0
- package/dist/limits.js +251 -0
- package/dist/limits.js.map +1 -0
- package/dist/main.js +177 -33
- package/dist/main.js.map +1 -1
- package/dist/picker.d.ts +10 -0
- package/dist/picker.js +150 -0
- package/dist/picker.js.map +1 -0
- package/dist/preflight.d.ts +43 -0
- package/dist/preflight.js +133 -19
- package/dist/preflight.js.map +1 -1
- package/dist/repopicker.js +8 -96
- package/dist/repopicker.js.map +1 -1
- package/dist/runs.d.ts +19 -4
- package/dist/runs.js +74 -9
- package/dist/runs.js.map +1 -1
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +1152 -138
- package/dist/serve.js.map +1 -1
- package/dist/session.d.ts +51 -0
- package/dist/session.js +145 -0
- package/dist/session.js.map +1 -0
- package/dist/settle.d.ts +18 -0
- package/dist/settle.js +39 -0
- package/dist/settle.js.map +1 -0
- package/dist/signin.d.ts +47 -0
- package/dist/signin.js +331 -0
- package/dist/signin.js.map +1 -0
- package/dist/stream.d.ts +8 -0
- package/dist/stream.js +44 -3
- package/dist/stream.js.map +1 -1
- package/dist/subscriptions.d.ts +115 -0
- package/dist/subscriptions.js +140 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/team.js +7 -1
- package/dist/team.js.map +1 -1
- package/dist/tmux.d.ts +1 -0
- package/dist/tmux.js +47 -0
- package/dist/tmux.js.map +1 -0
- package/dist/ui.d.ts +13 -0
- package/dist/ui.js +7 -2
- package/dist/ui.js.map +1 -1
- package/dist/usage.d.ts +105 -0
- package/dist/usage.js +348 -0
- package/dist/usage.js.map +1 -0
- package/dist/wall.d.ts +35 -0
- package/dist/wall.js +94 -0
- package/dist/wall.js.map +1 -0
- package/dist/workspace.d.ts +15 -1
- package/dist/workspace.js +25 -1
- package/dist/workspace.js.map +1 -1
- package/dist/worktree.d.ts +19 -0
- package/dist/worktree.js +179 -4
- package/dist/worktree.js.map +1 -1
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
1
3
|
import { createRequire } from 'node:module';
|
|
2
|
-
import {
|
|
4
|
+
import { ensureTmuxScrollback } from './tmux.js';
|
|
5
|
+
import { START_SCRIPT as START_SCRIPT_NAME, webPart, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
|
|
3
6
|
import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
|
|
4
7
|
import { freshGitHubToken } from './githubauth.js';
|
|
5
8
|
import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
|
|
@@ -10,10 +13,14 @@ import { preflightFlaredeck } from './flaredeck-check.js';
|
|
|
10
13
|
import { NOT_FOR_WORKERS, REPLACED_BY_HARNESS, callFlaredeckTool, flaredeckProxy } from './flaredeck-proxy.js';
|
|
11
14
|
import { SCRIPT_REQUEST, allTargets, resumeApprovedRuns, runTools } from './runs.js';
|
|
12
15
|
import { deliverDecision, flaredeckDecider } from './approval.js';
|
|
16
|
+
import { subscriptionsForAgent } from './subscriptions.js';
|
|
13
17
|
import { CouldNotWork, engineFor } from './engine.js';
|
|
18
|
+
/** The run failed on its Claude subscription — capped or token rejected — not on its work. */
|
|
19
|
+
class RetryOnAnotherPlan extends CouldNotWork {
|
|
20
|
+
}
|
|
14
21
|
import { jevGuard } from './jevguard.js';
|
|
15
22
|
import { loadProviders } from './providers.js';
|
|
16
|
-
import { currentBranch, ensureWorktree, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
|
|
23
|
+
import { currentBranch, ensureWorktree, freeDiskBytes, sweepWorktrees, releaseSharedPushGuard, existingWorktree, produced, releaseWorktree, tooEarlyToFinish, uncommittedCount, worktreeBrief } from './worktree.js';
|
|
17
24
|
import { askingForComponent, checkouts, resolveCheckout, voteByCode, workspaceBrief } from './workspace.js';
|
|
18
25
|
import { intake, intakeText, unclearBrief } from './intake.js';
|
|
19
26
|
import { standingBrief } from './agent.js';
|
|
@@ -21,13 +28,16 @@ import { startUi } from './ui.js';
|
|
|
21
28
|
import { checkAndStage, harnessVersion, installedSdkVersion, pendingUpdate, updatesEnabled } from './updater.js';
|
|
22
29
|
import { updateSidecarSdk } from './updater-python.js';
|
|
23
30
|
import { managedPython, usingManagedPython } from './engine-antigravity.js';
|
|
24
|
-
import { mkdirSync } from 'node:fs';
|
|
31
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
25
32
|
import { join } from 'node:path';
|
|
26
|
-
import { configDir, saveConfig } from './config.js';
|
|
27
|
-
import {
|
|
33
|
+
import { configDir, loadGitHub, saveConfig } from './config.js';
|
|
34
|
+
import { boardArg, onMyBoards, outstandingCards } from './boards.js';
|
|
35
|
+
import { browserTools, ensureBrowser } from './browser.js';
|
|
36
|
+
import { environmentTools } from './envtools.js';
|
|
28
37
|
import { watchForDesperation } from './desperation.js';
|
|
29
38
|
import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
|
|
30
|
-
import { EventStream } from './stream.js';
|
|
39
|
+
import { EventStream, describeFailure } from './stream.js';
|
|
40
|
+
import { canPublish, canPushTo } from './githubauth.js';
|
|
31
41
|
/**
|
|
32
42
|
* The always-on agent.
|
|
33
43
|
*
|
|
@@ -43,8 +53,64 @@ import { EventStream } from './stream.js';
|
|
|
43
53
|
const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
44
54
|
/** How often the agent says it is alive, whether or not anything is happening. */
|
|
45
55
|
const HEARTBEAT_MS = 5 * 60 * 1000;
|
|
56
|
+
/** A card worktree unused this long, with nothing unpublished on it, is released. */
|
|
57
|
+
const WORKTREE_IDLE_MS = 12 * 60 * 60 * 1000;
|
|
58
|
+
/** Short of disk, an hour unused is enough: the card comes back from what it pushed. */
|
|
59
|
+
/**
|
|
60
|
+
* Every pane line, full length, on disk: `<config>/agent.log`.
|
|
61
|
+
*
|
|
62
|
+
* The pane truncates and scrolls away, and the per-card journal keeps only 40 short lines and
|
|
63
|
+
* only while a card runs — so anything between cards (startup, usage reads, pauses) existed
|
|
64
|
+
* nowhere once it left the screen, and "what did it say?" meant a screenshot. Rotated at 5 MB to
|
|
65
|
+
* one `.1`, so a daemon that runs for weeks cannot fill the disk it is also guarding.
|
|
66
|
+
*/
|
|
67
|
+
const AGENT_LOG_MAX = 5 * 1024 * 1024;
|
|
68
|
+
function writeAgentLog(line) {
|
|
69
|
+
try {
|
|
70
|
+
const path = join(configDir(), 'agent.log');
|
|
71
|
+
if (existsSync(path) && statSync(path).size > AGENT_LOG_MAX)
|
|
72
|
+
renameSync(path, `${path}.1`);
|
|
73
|
+
appendFileSync(path, `${new Date().toISOString()} ${line.kind.padEnd(5)} ${line.card ? `[${line.card}] ` : ''}${line.text.replace(/\n/g, '\n ')}\n`);
|
|
74
|
+
}
|
|
75
|
+
catch { /* a log that cannot be written must never stop the agent */ }
|
|
76
|
+
}
|
|
77
|
+
const WORKTREE_IDLE_LOW_DISK_MS = 60 * 60 * 1000;
|
|
78
|
+
/** Below this, idle worktrees are released early. */
|
|
79
|
+
const DISK_LOW = 30e9;
|
|
80
|
+
/** Below this, no card is started. */
|
|
81
|
+
const DISK_MIN = 10e9;
|
|
46
82
|
export async function serve(opts) {
|
|
47
83
|
const { config } = opts;
|
|
84
|
+
/** The tenant the agent's own token is for. Reads the claim rather than the config file,
|
|
85
|
+
* because the token is what the server authorises against. */
|
|
86
|
+
const tenantFromToken = (token) => {
|
|
87
|
+
try {
|
|
88
|
+
const part = token.split('.')[1] ?? '';
|
|
89
|
+
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')).tenantId ?? null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/** When each plan's usage was last read, so a burst of short events does not read it each time. */
|
|
96
|
+
const usageReadAt = new Map();
|
|
97
|
+
const USAGE_REFRESH_MS = 5 * 60_000;
|
|
98
|
+
/** The plan the event being handled ran on, so its usage can be read once the event is done. */
|
|
99
|
+
let turnPlan = null;
|
|
100
|
+
/** Events parked until a Claude plan resets, by event id — one timer each, never two. */
|
|
101
|
+
const waitingForPlan = new Map();
|
|
102
|
+
/** The run reported its plan's usage itself, so no separate read is needed after it. */
|
|
103
|
+
let turnSawUsage = false;
|
|
104
|
+
/**
|
|
105
|
+
* Plans that failed on an event, by event id, so its retry is offered a different one.
|
|
106
|
+
*
|
|
107
|
+
* A plan that hits its cap or has its token rejected used to fail the card outright while the
|
|
108
|
+
* other plans sat unused. Now the event goes straight back on the queue and `choose` is told
|
|
109
|
+
* what not to hand out. Bounded by the plans that exist — each retry spends one — and by
|
|
110
|
+
* MAX_PLAN_SWITCHES as a backstop.
|
|
111
|
+
*/
|
|
112
|
+
const failedPlans = new Map();
|
|
113
|
+
const MAX_PLAN_SWITCHES = 4;
|
|
48
114
|
const stream = new EventStream(opts.baseUrl, opts.auth);
|
|
49
115
|
const state = {
|
|
50
116
|
version: harnessVersion(),
|
|
@@ -72,6 +138,7 @@ export async function serve(opts) {
|
|
|
72
138
|
if (state.lines.length > 400)
|
|
73
139
|
state.lines.splice(0, state.lines.length - 400);
|
|
74
140
|
ui?.log(line);
|
|
141
|
+
writeAgentLog(line);
|
|
75
142
|
};
|
|
76
143
|
const updated = process.env['FLAREDECK_AGENT_UPDATED'];
|
|
77
144
|
if (updated)
|
|
@@ -166,14 +233,20 @@ export async function serve(opts) {
|
|
|
166
233
|
* with no code in it — which is right: a message is not authority to start work, only to carry
|
|
167
234
|
* on with work that exists.
|
|
168
235
|
*/
|
|
169
|
-
const worktreeTheMessageIsAbout = async (event, repo, onStep
|
|
236
|
+
const worktreeTheMessageIsAbout = async (event, repo, onStep,
|
|
237
|
+
/** Makes one when none exists — see the note where this is called. */
|
|
238
|
+
make) => {
|
|
170
239
|
// `ask` too: a question typed in the pane is usually about the card in front of the person,
|
|
171
240
|
// and without this the turn has no checkout and therefore no publishing tools — so an agent
|
|
172
241
|
// asked to commit its work truthfully answers that it cannot.
|
|
173
242
|
if (!['message', 'commented', 'ask'].includes(event.event))
|
|
174
243
|
return null;
|
|
175
244
|
const text = `${event.line ?? ''} ${event.message ?? ''}`;
|
|
176
|
-
|
|
245
|
+
// Prose a person typed, so there is no field to read and a pattern is the only way. The
|
|
246
|
+
// bound was ten characters before the dash, which silently excluded real boards —
|
|
247
|
+
// DOCUMENTREPOSITOR-0022 is seventeen. Where a TOOL answers, the code is read from the
|
|
248
|
+
// fields it prints instead; see outstandingCards.
|
|
249
|
+
const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,23}-\d{3,}\b/g) ?? [])];
|
|
177
250
|
for (const code of codes) {
|
|
178
251
|
// What the harness wrote down when it made the worktree, first: the directory is named from
|
|
179
252
|
// the card's internal id and the branch from whatever the agent chose, so neither can be
|
|
@@ -190,6 +263,22 @@ export async function serve(opts) {
|
|
|
190
263
|
tag = code;
|
|
191
264
|
return found;
|
|
192
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Nothing there yet, and the person at this machine named it — so make it.
|
|
268
|
+
*
|
|
269
|
+
* This is what stopped "work KAN-0074" working: a card nobody had touched had no checkout,
|
|
270
|
+
* and the agent rightly refused to run it from the shared one, which was on another branch
|
|
271
|
+
* with uncommitted changes. Only for a pane ask: a comment or a message naming a card is
|
|
272
|
+
* usually talking ABOUT it, and a card event makes its own.
|
|
273
|
+
*/
|
|
274
|
+
if (event.event === 'ask') {
|
|
275
|
+
const made = await make(code).catch(() => null);
|
|
276
|
+
if (made) {
|
|
277
|
+
onStep(`made a checkout for ${code}, ${made.branch}`);
|
|
278
|
+
tag = code;
|
|
279
|
+
return made;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
193
282
|
}
|
|
194
283
|
return null;
|
|
195
284
|
};
|
|
@@ -228,6 +317,63 @@ export async function serve(opts) {
|
|
|
228
317
|
if (await releaseSharedPushGuard(c.path).catch(() => false))
|
|
229
318
|
log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
|
|
230
319
|
}
|
|
320
|
+
// Worktrees left by earlier cards — see sweepWorktrees. In the background: nothing waits on it.
|
|
321
|
+
void (async () => {
|
|
322
|
+
for (const c of [{ path: config.repo }, ...inScope])
|
|
323
|
+
await sweepWorktrees(c.path, WORKTREE_IDLE_MS, (t) => log('tool', t)).catch(() => { });
|
|
324
|
+
})();
|
|
325
|
+
// Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
|
|
326
|
+
void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
|
|
327
|
+
// The browser, fetched while nothing is waiting on it. 186MB is a long pause to take in the
|
|
328
|
+
// middle of the first card that needs eyes, and the agent is idle right now.
|
|
329
|
+
void ensureBrowser((t) => log('tool', `browser: ${t}`)).catch(() => { });
|
|
330
|
+
/**
|
|
331
|
+
* The workspace's MCP servers, so `flaredeck-agent login` is the only credential step on a
|
|
332
|
+
* machine. Connect Sceneflo once in FlareDeck and every agent has it; rotate its token there
|
|
333
|
+
* and every agent follows on its next start.
|
|
334
|
+
*
|
|
335
|
+
* Awaited, unlike the browser fetch above, because the first card must not run with a list
|
|
336
|
+
* that is about to change underneath it — an agent that starts without a server it should
|
|
337
|
+
* have reports a missing tool, which reads as a product fault rather than a race.
|
|
338
|
+
*/
|
|
339
|
+
/**
|
|
340
|
+
* Re-fetched before every session, not only at start: a signed-in server's access token lasts
|
|
341
|
+
* about an hour, so a list fetched once at 08:00 hands out a dead token by 09:00. FlareDeck
|
|
342
|
+
* refreshes it centrally; asking again is all it takes. Merged over the machine's ORIGINAL local
|
|
343
|
+
* list each time, so a server removed in FlareDeck does not linger as "local".
|
|
344
|
+
*
|
|
345
|
+
* Returns the REQUIRED servers that could not be connected — null when FlareDeck could not be
|
|
346
|
+
* asked, which must never pause an agent: not knowing is not the same as knowing one is missing.
|
|
347
|
+
*/
|
|
348
|
+
const localMcp = [...(config.mcpServers ?? [])];
|
|
349
|
+
let lastMcpNote = '';
|
|
350
|
+
const refreshMcp = async () => {
|
|
351
|
+
try {
|
|
352
|
+
const { hydrateMcpServers, mcpConfigFetcher } = await import('./hydrate.js');
|
|
353
|
+
const tenantForMcp = tenantFromToken(await opts.auth());
|
|
354
|
+
if (!tenantForMcp)
|
|
355
|
+
return null;
|
|
356
|
+
const hydrated = await hydrateMcpServers({ mcpServers: localMcp }, mcpConfigFetcher(config.mcpUrl, tenantForMcp, () => opts.auth()));
|
|
357
|
+
config.mcpServers = hydrated.mcpServers;
|
|
358
|
+
if (hydrated.note && hydrated.note !== lastMcpNote) {
|
|
359
|
+
lastMcpNote = hydrated.note;
|
|
360
|
+
log('tool', hydrated.note);
|
|
361
|
+
}
|
|
362
|
+
return hydrated.missingRequired ?? null;
|
|
363
|
+
}
|
|
364
|
+
catch (e) {
|
|
365
|
+
// Never fatal: the machine's own list still works, which is what every agent had before.
|
|
366
|
+
log('warn', `could not fetch the MCP list: ${e instanceof Error ? e.message : String(e)}`);
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
const mcpWallFor = (missing) => ({
|
|
371
|
+
check: 'mcp',
|
|
372
|
+
reason: `A required MCP server is not connected — ${missing.map((m) => `${m.name}: ${m.why}`).join('; ')}. ` +
|
|
373
|
+
`Nothing is worked until it is; I resume by myself within a minute of it being connected.`,
|
|
374
|
+
});
|
|
375
|
+
// Awaited, because the first card must not run with a list about to change underneath it.
|
|
376
|
+
await refreshMcp();
|
|
231
377
|
// A restart begins with no environments: the last run's are nobody's now (see the function).
|
|
232
378
|
for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
|
|
233
379
|
void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
|
|
@@ -336,13 +482,22 @@ export async function serve(opts) {
|
|
|
336
482
|
const reader = opts.me ?? opts.deck ?? null;
|
|
337
483
|
const answeredHere = new Map();
|
|
338
484
|
let refreshing = null;
|
|
485
|
+
/** This agent's own id, and (on a lead) the agents that report to it — whose requests it sees. */
|
|
486
|
+
let selfId = '';
|
|
487
|
+
let teamIds = new Set();
|
|
339
488
|
const refreshApprovals = () => {
|
|
340
489
|
if (!reader)
|
|
341
490
|
return Promise.resolve();
|
|
342
491
|
if (refreshing)
|
|
343
492
|
return refreshing;
|
|
344
493
|
refreshing = reader.pendingApprovals().then((all) => {
|
|
345
|
-
|
|
494
|
+
// Its own requests, and on a lead its team's. Every pending request in the workspace used
|
|
495
|
+
// to be listed on every pane, so CASE showed HAL's request as if it were CASE's business.
|
|
496
|
+
// Until this agent knows who it is (the team read failed), nothing is hidden.
|
|
497
|
+
const ours = (a) => !selfId
|
|
498
|
+
|| a.sender_key === selfId
|
|
499
|
+
|| (isLead && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
|
|
500
|
+
const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST && ours(a));
|
|
346
501
|
state.approvals = mine.map((a) => {
|
|
347
502
|
const lines = (a.input_preview || '').split('\n');
|
|
348
503
|
const preview = lines.find((l) => l.startsWith('-- RAN') || /would_touch/.test(l))?.replace(/^-- /, '').replace(/^harness preview.*?:\s*/, '');
|
|
@@ -368,7 +523,7 @@ export async function serve(opts) {
|
|
|
368
523
|
for (const id of [...answeredHere.keys()])
|
|
369
524
|
if (!mine.some((a) => a.request_id === id))
|
|
370
525
|
answeredHere.delete(id);
|
|
371
|
-
}).catch((e) => log('warn', `could not read the approvals list: ${
|
|
526
|
+
}).catch((e) => log('warn', `could not read the approvals list: ${describeFailure(e)}`))
|
|
372
527
|
.finally(() => { refreshing = null; });
|
|
373
528
|
return refreshing;
|
|
374
529
|
};
|
|
@@ -451,35 +606,65 @@ export async function serve(opts) {
|
|
|
451
606
|
// configured production targets are added to the shell every machine already has.
|
|
452
607
|
const runsSet = opts.deck
|
|
453
608
|
? runTools(config.runs ?? {}, () => onCard, opts.deck, (t) => log('tool', t), {
|
|
454
|
-
|
|
609
|
+
attach: (card, path, comment) => attachFile(card, path, comment),
|
|
610
|
+
flag: async (reason, kind, ask) => {
|
|
455
611
|
const cur = onCard;
|
|
456
612
|
if (!cur)
|
|
457
613
|
return null;
|
|
458
|
-
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
|
|
614
|
+
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind, ask);
|
|
459
615
|
},
|
|
460
|
-
unflag: async () => {
|
|
461
|
-
const cur = onCard;
|
|
462
|
-
if (!cur)
|
|
463
|
-
return;
|
|
616
|
+
unflag: async (cur) => {
|
|
464
617
|
parked.delete(cur.card);
|
|
465
618
|
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
|
|
466
619
|
card_id: cur.code || cur.card,
|
|
467
620
|
...(cur.board ? { board_id: cur.board } : {}),
|
|
468
621
|
}).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
|
|
469
622
|
},
|
|
470
|
-
}, () => leadId, onAsk)
|
|
623
|
+
}, () => leadId, onAsk, (card, text) => resumeParked(card, text))
|
|
471
624
|
: undefined;
|
|
472
625
|
// Answering from this keyboard: the person's own login decides, through the same endpoint the
|
|
473
626
|
// phone uses, so the agent waiting on it sees the verdict as any other.
|
|
474
|
-
|
|
627
|
+
/**
|
|
628
|
+
* The person answering from this pane. Signed in once per machine, from the pane when nobody
|
|
629
|
+
* has: press a/d and it asks. Never the agent's own login — an approval is a person's decision,
|
|
630
|
+
* and the agent has a shell on this box and its own credentials in it.
|
|
631
|
+
*/
|
|
632
|
+
let me = opts.me;
|
|
633
|
+
state.person = !!me;
|
|
634
|
+
const startSignIn = () => {
|
|
635
|
+
if (signingIn) {
|
|
636
|
+
log('event', 'still waiting for the sign-in — paste the address your browser ended on');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (!opts.signInMe) {
|
|
640
|
+
log('warn', 'nobody is signed in here to answer with — run: flaredeck-agent login --me');
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
signingIn = true;
|
|
644
|
+
log('event', 'Sign in as YOURSELF to answer from this pane (once per machine). Open the address below, sign in, then paste the address your browser ends up on into the box here and press Enter.');
|
|
645
|
+
opts.signInMe((t) => { if (t.trim())
|
|
646
|
+
log('event', t); }, signInReader)
|
|
647
|
+
.then((client) => {
|
|
648
|
+
me = client ?? undefined;
|
|
649
|
+
state.person = !!me;
|
|
650
|
+
log(me ? 'event' : 'warn', me ? 'signed in — a approves, d denies, from this pane' : 'signed in, but that login has no workspace here');
|
|
651
|
+
})
|
|
652
|
+
.catch((e) => log('warn', `sign-in did not finish: ${e instanceof Error ? e.message : String(e)}`))
|
|
653
|
+
.finally(() => { signingIn = false; });
|
|
654
|
+
};
|
|
655
|
+
const decide = opts.signInMe || opts.me
|
|
475
656
|
? (requestId, verdict) => {
|
|
657
|
+
if (!me) {
|
|
658
|
+
startSignIn();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
476
661
|
const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
|
|
477
662
|
if (!row || row.stage !== 'ready')
|
|
478
663
|
return;
|
|
479
664
|
answeredHere.set(requestId, verdict);
|
|
480
665
|
row.stage = 'answered';
|
|
481
666
|
row.verdict = verdict;
|
|
482
|
-
void
|
|
667
|
+
void me.decide(row.commentId, verdict)
|
|
483
668
|
.then(() => log('you', `${verdict === 'allow' ? 'approved' : 'denied'} ${row.name} (${row.agent}, ${row.card}) from here`))
|
|
484
669
|
.catch((e) => { answeredHere.delete(requestId); log('warn', `could not record that: ${e instanceof Error ? e.message : String(e)}`); void refreshApprovals(); });
|
|
485
670
|
}
|
|
@@ -563,6 +748,8 @@ export async function serve(opts) {
|
|
|
563
748
|
if (leadId)
|
|
564
749
|
log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
|
|
565
750
|
isLead = team.you.role === 'lead';
|
|
751
|
+
selfId = team.you.agentId;
|
|
752
|
+
teamIds = new Set(team.agents.filter((a) => a.reportsTo === team.you.agentId).map((a) => a.agentId));
|
|
566
753
|
if (team.you.role === 'lead') {
|
|
567
754
|
teamSet = {
|
|
568
755
|
// After a review the list changes — an endorsed request is now answerable, from THIS
|
|
@@ -587,11 +774,34 @@ export async function serve(opts) {
|
|
|
587
774
|
* Polled rather than pushed, because nothing streams another agent's status — and slowly,
|
|
588
775
|
* since agents check in every five minutes, so a faster poll would show the same row again.
|
|
589
776
|
*/
|
|
777
|
+
/**
|
|
778
|
+
* An idle worker is the lead's problem, and the lead already knows within a minute.
|
|
779
|
+
*
|
|
780
|
+
* It polls its team every sixty seconds to draw the pane, so "CASE idle 4m ago" is sitting on
|
|
781
|
+
* its screen — and it did nothing with it. Dispatch waited for the hourly sweep, or for the
|
|
782
|
+
* worker to say it was free, which for a while it never did. Eleven cards waited in Ready For
|
|
783
|
+
* Dev while two agents sat idle and the lead reported "waiting for work".
|
|
784
|
+
*
|
|
785
|
+
* The poll is already happening, so noticing costs nothing. Rate-limited because an idle
|
|
786
|
+
* worker with genuinely nothing to give it stays idle, and a lead asked about it every minute
|
|
787
|
+
* learns to say "nothing" without looking.
|
|
788
|
+
*/
|
|
789
|
+
const DISPATCH_AGAIN_MS = 10 * 60 * 1000;
|
|
790
|
+
let lastDispatch = 0;
|
|
791
|
+
const workersAreIdle = (team) => myTeam(team).mine.filter((a) => !a.card && a.state !== 'stopped' && !a.stale).map((a) => a.name);
|
|
590
792
|
if (leadOfTeam) {
|
|
591
793
|
const teamTimer = setInterval(() => {
|
|
592
794
|
void (async () => {
|
|
593
795
|
try {
|
|
594
|
-
|
|
796
|
+
const team = await fetchTeam(opts.baseUrl, await opts.auth());
|
|
797
|
+
showTeam(team);
|
|
798
|
+
const free = workersAreIdle(team);
|
|
799
|
+
if (free.length && !working && !queue.length && Date.now() - lastDispatch > DISPATCH_AGAIN_MS) {
|
|
800
|
+
lastDispatch = Date.now();
|
|
801
|
+
log('event', `${free.join(', ')} ${free.length === 1 ? 'is' : 'are'} idle — looking for work to hand over`);
|
|
802
|
+
if (enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: `${free.join(', ')} have nothing to do` }))
|
|
803
|
+
void drain();
|
|
804
|
+
}
|
|
595
805
|
}
|
|
596
806
|
catch {
|
|
597
807
|
// A team the lead briefly cannot see is not worth a line in the log every minute; the
|
|
@@ -623,14 +833,188 @@ export async function serve(opts) {
|
|
|
623
833
|
if (engine.name !== 'claude')
|
|
624
834
|
log('event', `${engine.name} is answering on this agent`);
|
|
625
835
|
let liveRun = null;
|
|
836
|
+
/** The checkout this agent's current or last card is in — never swept from under it. */
|
|
837
|
+
let currentTree = null;
|
|
838
|
+
const activeTrees = () => new Set(currentTree ? [currentTree] : []);
|
|
839
|
+
/**
|
|
840
|
+
* Paused: a wall this agent cannot get past by itself, so it does no work at all — no turns,
|
|
841
|
+
* no tokens — until its lead resumes it. See flaredeck-mcp/src/mcp/pause.ts for the why.
|
|
842
|
+
*/
|
|
843
|
+
let paused = null;
|
|
844
|
+
let readyReported = false;
|
|
845
|
+
/** Walls the agent cannot climb, checked in code — never by asking the model. */
|
|
846
|
+
const walls = async () => {
|
|
847
|
+
config.github = loadGitHub() ?? config.github;
|
|
848
|
+
const login = await canPublish(config.github).catch(() => null);
|
|
849
|
+
if (login)
|
|
850
|
+
return { check: 'github', reason: `GitHub refuses this machine's login, so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` };
|
|
851
|
+
if (config.github?.accessToken) {
|
|
852
|
+
for (const c of [{ path: config.repo }, ...(await checkouts(config.repo, config.repos))]) {
|
|
853
|
+
const remote = await new Promise((resolve) => {
|
|
854
|
+
execFile('git', ['remote', 'get-url', 'origin'], { cwd: c.path }, (err, out) => resolve(err ? '' : String(out).trim()));
|
|
855
|
+
});
|
|
856
|
+
const why = remote ? await canPushTo(config.github.accessToken, remote) : null;
|
|
857
|
+
if (why)
|
|
858
|
+
return { check: 'github', reason: `${why} Nothing I write in ${c.path} could be published. Fix the access, or sign this machine in with an account that has it.` };
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const missing = await refreshMcp();
|
|
862
|
+
if (missing?.length)
|
|
863
|
+
return mcpWallFor(missing);
|
|
864
|
+
const free = freeDiskBytes(config.repo);
|
|
865
|
+
if (free !== null && free < DISK_MIN) {
|
|
866
|
+
await sweepWorktrees(config.repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
|
|
867
|
+
const after = freeDiskBytes(config.repo);
|
|
868
|
+
if (after !== null && after < DISK_MIN) {
|
|
869
|
+
return { check: 'disk', reason: `This machine has only ${(after / 1e9).toFixed(1)} GB of disk free, and builds, Docker and test environments all fail when it fills. Idle worktrees are already cleared; free space (Docker images, caches, old checkouts).` };
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
};
|
|
874
|
+
const enterPause = async (wall) => {
|
|
875
|
+
if (paused?.check === wall.check)
|
|
876
|
+
return;
|
|
877
|
+
paused = wall;
|
|
878
|
+
readyReported = false;
|
|
879
|
+
state.paused = wall.reason;
|
|
880
|
+
log('warn', `PAUSED (${wall.check}): ${wall.reason}`);
|
|
881
|
+
void liveRun?.stop().catch(() => { });
|
|
882
|
+
try {
|
|
883
|
+
const r = await opts.deck?.pause(wall.check, wall.reason);
|
|
884
|
+
/* A solo agent has no lead: its cards stay with it (each gets a comment) and nobody is
|
|
885
|
+
messaged. Saying "told the lead, handed back …" then described something that never
|
|
886
|
+
happened. */
|
|
887
|
+
if (r && !r.already) {
|
|
888
|
+
log('event', r.lead
|
|
889
|
+
? `told the lead${r.handedBack.length ? `, handed back ${r.handedBack.join(', ')}` : ''} — doing nothing until resumed`
|
|
890
|
+
: `no lead to tell — ${r.handedBack.length ? `left a note on ${r.handedBack.join(', ')}; they stay mine` : 'nothing handed back'}. ${wall.check === 'disk' ? 'I resume myself once the disk is clear.' : wall.check === 'mcp' ? 'I resume myself once it is connected.' : 'A person resumes me.'}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (e) {
|
|
894
|
+
log('warn', `could not tell FlareDeck I am paused: ${e instanceof Error ? e.message : String(e)}`);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
// While paused: ask FlareDeck every minute whether the lead has resumed this agent (one REST
|
|
898
|
+
// call, no model), and re-check the wall every ten, saying once when it is gone.
|
|
899
|
+
let lastWallCheck = 0;
|
|
900
|
+
const pauseTick = setInterval(() => {
|
|
901
|
+
if (!paused)
|
|
902
|
+
return;
|
|
903
|
+
void (async () => {
|
|
904
|
+
const server = await opts.deck?.pauseState().catch(() => undefined);
|
|
905
|
+
if (server === null) {
|
|
906
|
+
const still = await walls();
|
|
907
|
+
if (still) {
|
|
908
|
+
paused = null;
|
|
909
|
+
await enterPause(still);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
log('event', `resumed by the lead — back to work`);
|
|
913
|
+
paused = null;
|
|
914
|
+
state.paused = undefined;
|
|
915
|
+
void resumeOwnWork();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
/* Disk is a statfs call, so it is checked every tick; the others ask GitHub, so every ten
|
|
919
|
+
minutes. A disk wall that has cleared resumes the agent at once — there is nothing for a
|
|
920
|
+
lead to decide about a number, and waiting for one left the agent idle for hours after
|
|
921
|
+
the space had already been freed. */
|
|
922
|
+
const selfClearing = paused.check === 'disk' || paused.check === 'mcp';
|
|
923
|
+
if (selfClearing) {
|
|
924
|
+
let cleared = '';
|
|
925
|
+
if (paused.check === 'disk') {
|
|
926
|
+
const free = freeDiskBytes(config.repo);
|
|
927
|
+
if (free === null || free < DISK_MIN)
|
|
928
|
+
return;
|
|
929
|
+
cleared = `disk is clear (${(free / 1e9).toFixed(0)} GB free)`;
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
// One HTTP call a minute while paused; null (FlareDeck unreachable) is not "connected".
|
|
933
|
+
const missing = await refreshMcp();
|
|
934
|
+
if (missing === null || missing.length)
|
|
935
|
+
return;
|
|
936
|
+
cleared = 'the required MCP servers are connected';
|
|
937
|
+
}
|
|
938
|
+
const r = await opts.deck?.resumeSelf().catch(() => null);
|
|
939
|
+
if (r?.resumed) {
|
|
940
|
+
log('event', `${cleared} — resumed myself and told the lead`);
|
|
941
|
+
paused = null;
|
|
942
|
+
state.paused = undefined;
|
|
943
|
+
readyReported = false;
|
|
944
|
+
void resumeOwnWork();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// An older server without self-resume: fall through to telling the lead, as before.
|
|
948
|
+
}
|
|
949
|
+
if ((!selfClearing && Date.now() - lastWallCheck < 10 * 60 * 1000) || readyReported)
|
|
950
|
+
return;
|
|
951
|
+
lastWallCheck = Date.now();
|
|
952
|
+
if (!(await walls())) {
|
|
953
|
+
readyReported = true;
|
|
954
|
+
log('event', `${paused.check} is fixed — told the lead; waiting to be resumed`);
|
|
955
|
+
await opts.deck?.ready().catch(() => { });
|
|
956
|
+
}
|
|
957
|
+
})();
|
|
958
|
+
}, 60 * 1000);
|
|
959
|
+
pauseTick.unref?.();
|
|
960
|
+
/**
|
|
961
|
+
* This turn's browser, so it can be closed when the turn ends.
|
|
962
|
+
*
|
|
963
|
+
* A fresh tool set is built per turn, so without this the previous turn's Chromium was both
|
|
964
|
+
* unreachable and still running — a leaked browser tree for every turn that looked at
|
|
965
|
+
* anything, on a process that stays up for days.
|
|
966
|
+
*/
|
|
967
|
+
let turnEyes = null;
|
|
626
968
|
/**
|
|
627
969
|
* Typed with nothing running: a question for the agent, answered now. It goes through the same
|
|
628
970
|
* queue as a card event so it is serialised with real work, but it is local — never acknowledged
|
|
629
971
|
* to the server, never a card — and it opens one long-lived session of its own so a follow-up
|
|
630
972
|
* question remembers the last one.
|
|
631
973
|
*/
|
|
974
|
+
/**
|
|
975
|
+
* A parked card's request was answered: put the card back at the FRONT of the queue, resuming
|
|
976
|
+
* its own session in its own worktree, with what happened. The turn on whatever card is running
|
|
977
|
+
* now finishes first.
|
|
978
|
+
*/
|
|
979
|
+
const resumeParked = (card, text) => {
|
|
980
|
+
const event = {
|
|
981
|
+
id: `run-${card.card}-${Date.now()}`,
|
|
982
|
+
event: 'run-answered',
|
|
983
|
+
line: text,
|
|
984
|
+
message: text,
|
|
985
|
+
cardKey: card.card,
|
|
986
|
+
...(card.board ? { boardKey: card.board } : {}),
|
|
987
|
+
...(card.code ? { cardCode: card.code } : {}),
|
|
988
|
+
};
|
|
989
|
+
log('event', `${card.code || card.card}: request answered — back at the front of the queue`);
|
|
990
|
+
if (enqueue(event, true)) {
|
|
991
|
+
void drain();
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
/**
|
|
995
|
+
* A question typed in this pane.
|
|
996
|
+
*
|
|
997
|
+
* Whatever it says is passed through untouched, with one line in front of it saying who is
|
|
998
|
+
* speaking. Nothing here tries to work out whether it is a question or an instruction: a
|
|
999
|
+
* pattern that guessed got it wrong both ways — "work KAN-0074" did nothing because a bare
|
|
1000
|
+
* question never makes a worktree, and anything clever enough to catch that also caught
|
|
1001
|
+
* "work out why KAN-0074 failed" and started a build nobody asked for. The model reads English
|
|
1002
|
+
* better than a regular expression does; it only needed telling whose English it is.
|
|
1003
|
+
*
|
|
1004
|
+
* The authority is real and worth stating plainly. This person is at the machine. They can do
|
|
1005
|
+
* anything the agent can do and more, directly, without asking — so there is nothing to
|
|
1006
|
+
* protect here, and treating their instruction as a suggestion only wastes their time.
|
|
1007
|
+
*/
|
|
632
1008
|
const ask = (text) => {
|
|
633
|
-
const event = {
|
|
1009
|
+
const event = {
|
|
1010
|
+
id: `ask-${Date.now()}`,
|
|
1011
|
+
event: 'ask',
|
|
1012
|
+
line: text,
|
|
1013
|
+
message: 'This was typed by the person at this machine, in the agent\'s own terminal. They have ' +
|
|
1014
|
+
'every permission you have and more, and they are not waiting on anyone to approve it. ' +
|
|
1015
|
+
'Read it as what it plainly says: answer it if it is a question, do it if it is an ' +
|
|
1016
|
+
'instruction, and do not ask whether you may.\n\n' + text,
|
|
1017
|
+
};
|
|
634
1018
|
if (enqueue(event)) {
|
|
635
1019
|
prepareAhead(event);
|
|
636
1020
|
void drain();
|
|
@@ -643,6 +1027,9 @@ export async function serve(opts) {
|
|
|
643
1027
|
* blocking call here would freeze the pane and the agent's own event stream for as long as the
|
|
644
1028
|
* command took. The agent never sees it; say on the card or in a steer if it should know.
|
|
645
1029
|
*/
|
|
1030
|
+
/** A person signing in from the pane: lines typed meanwhile go to the sign-in, not the agent. */
|
|
1031
|
+
let signingIn = false;
|
|
1032
|
+
const signInReader = Object.assign(new EventEmitter(), { prompt() { }, setPrompt() { }, close() { } });
|
|
646
1033
|
/** The checkout the current turn is working in ('' for one that has none). */
|
|
647
1034
|
let turnCwd = '';
|
|
648
1035
|
/** Worktrees whose environment comes down unless a turn returns to them first. */
|
|
@@ -690,6 +1077,11 @@ export async function serve(opts) {
|
|
|
690
1077
|
});
|
|
691
1078
|
};
|
|
692
1079
|
const steer = (text) => {
|
|
1080
|
+
// Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
|
|
1081
|
+
if (signingIn) {
|
|
1082
|
+
signInReader.emit('line', text);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
693
1085
|
if (text.startsWith('!')) {
|
|
694
1086
|
const command = text.slice(1).trim();
|
|
695
1087
|
if (command)
|
|
@@ -707,7 +1099,21 @@ export async function serve(opts) {
|
|
|
707
1099
|
// every in-process FlareDeck call comes back on. From then until the turn ends, every
|
|
708
1100
|
// FlareDeck call fails as "interrupted" and the agent concludes FlareDeck is down. 'next'
|
|
709
1101
|
// waits for the tool batch to return and lands at the boundary, with nothing cut off.
|
|
710
|
-
|
|
1102
|
+
/* A run that was stopped (a pause stops it) can still be referenced here for a moment. If
|
|
1103
|
+
it will not take the line, it is not running: answer it as a question instead of
|
|
1104
|
+
dropping what the person typed. */
|
|
1105
|
+
const run = liveRun;
|
|
1106
|
+
void run.steer(text).then((heard) => {
|
|
1107
|
+
if (heard !== false)
|
|
1108
|
+
return;
|
|
1109
|
+
if (liveRun === run)
|
|
1110
|
+
liveRun = null;
|
|
1111
|
+
ask(text);
|
|
1112
|
+
}).catch(() => {
|
|
1113
|
+
if (liveRun === run)
|
|
1114
|
+
liveRun = null;
|
|
1115
|
+
ask(text);
|
|
1116
|
+
});
|
|
711
1117
|
}
|
|
712
1118
|
else {
|
|
713
1119
|
ask(text);
|
|
@@ -734,7 +1140,13 @@ export async function serve(opts) {
|
|
|
734
1140
|
* which point the SDK compacts silently. So the live turn is told, once, to wrap up: finish
|
|
735
1141
|
* the step, leave the handover, stop. Deliberate, and it happens while there is still room.
|
|
736
1142
|
*/
|
|
737
|
-
|
|
1143
|
+
//
|
|
1144
|
+
// OFF by default now. Stopping an agent part-way through a card cost more than compaction did:
|
|
1145
|
+
// the card was handed to a fresh session that re-read everything to get back to where the old
|
|
1146
|
+
// one already was. The end-of-card handover still happens. Set FLAREDECK_AGENT_WRAP_UP_AT to a
|
|
1147
|
+
// token count to bring the mid-turn nudge back.
|
|
1148
|
+
const wrapUpAt = Number(process.env['FLAREDECK_AGENT_WRAP_UP_AT']);
|
|
1149
|
+
const CONTEXT_WRAP_UP_TOKENS = Number.isFinite(wrapUpAt) && wrapUpAt > 0 ? wrapUpAt : Infinity;
|
|
738
1150
|
const keepAlive = (key, cwd, tokens) => {
|
|
739
1151
|
const prev = live.get(key);
|
|
740
1152
|
if (prev?.timer)
|
|
@@ -760,6 +1172,11 @@ export async function serve(opts) {
|
|
|
760
1172
|
const pending = new Set();
|
|
761
1173
|
/** Keep the pushed status in step with the queue — it is what a lead's pane reads. */
|
|
762
1174
|
const syncQueue = () => status.setQueue(queue.length, queue[0]?.cardCode || queue[0]?.cardKey || '');
|
|
1175
|
+
/**
|
|
1176
|
+
* The boards this agent works, by key. Empty means all of them, which is what every install
|
|
1177
|
+
* did before this existed.
|
|
1178
|
+
*/
|
|
1179
|
+
const myBoards = new Set((config.boards ?? []).map((b) => b.key));
|
|
763
1180
|
const enqueue = (event, front = false) => {
|
|
764
1181
|
if (pending.has(event.id))
|
|
765
1182
|
return false;
|
|
@@ -785,6 +1202,8 @@ export async function serve(opts) {
|
|
|
785
1202
|
const wantsIntake = (event) => !!event.cardKey
|
|
786
1203
|
&& event.event !== 'review'
|
|
787
1204
|
&& event.event !== 'ask'
|
|
1205
|
+
// A parked card coming back is the same work resuming, not a new card to brief.
|
|
1206
|
+
&& event.event !== 'run-answered'
|
|
788
1207
|
&& !(event.event === 'commented' && event.cardDone && !event.mentioned)
|
|
789
1208
|
// Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
|
|
790
1209
|
// a card this agent once touched keeps sending it every move and comment made by whoever
|
|
@@ -882,15 +1301,85 @@ export async function serve(opts) {
|
|
|
882
1301
|
const sweepMinutes = Number(process.env['FLAREDECK_AGENT_SWEEP_MINUTES']);
|
|
883
1302
|
const SWEEP_EVERY_MS = Math.max(5, Number.isFinite(sweepMinutes) ? sweepMinutes : 60) * 60 * 1000;
|
|
884
1303
|
const sweepsEnabled = process.env['FLAREDECK_AGENT_NO_SWEEP'] !== '1';
|
|
1304
|
+
/**
|
|
1305
|
+
* Read a plan's usage and post every window to the ledger.
|
|
1306
|
+
*
|
|
1307
|
+
* This is what the Secrets bars and `choose` route on. It used to happen only when `choose`
|
|
1308
|
+
* handed a plan over, at most once an hour, in the background — so the plans a run did not get
|
|
1309
|
+
* stayed "not measured yet" for days, and `choose` ranked on nothing and handed out a plan that
|
|
1310
|
+
* was already at its weekly cap. Now: every plan at startup, and the plan a card used once the
|
|
1311
|
+
* card is done, which is when its numbers have just moved.
|
|
1312
|
+
*
|
|
1313
|
+
* Never throws and never blocks work: a failed reading costs a little routing accuracy.
|
|
1314
|
+
*/
|
|
1315
|
+
const measurePlan = async (subs, plan, force = false) => {
|
|
1316
|
+
if (!subs)
|
|
1317
|
+
return;
|
|
1318
|
+
const last = usageReadAt.get(plan.id) ?? 0;
|
|
1319
|
+
if (!force && Date.now() - last < USAGE_REFRESH_MS)
|
|
1320
|
+
return;
|
|
1321
|
+
usageReadAt.set(plan.id, Date.now());
|
|
1322
|
+
try {
|
|
1323
|
+
const { readUsage } = await import('./usage.js');
|
|
1324
|
+
let why = '';
|
|
1325
|
+
const windows = await readUsage({ token: plan.token, onEmpty: (w) => { why = w; } });
|
|
1326
|
+
if (!windows.length) {
|
|
1327
|
+
log('warn', `claude usage: ${plan.label} — could not read it (${why || 'no detail'})`);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
for (const w of windows)
|
|
1331
|
+
await subs.seen(plan.id, w.window, w.utilization, w.resetsAt);
|
|
1332
|
+
log('tool', `claude usage: ${plan.label} · ${windows.map((w) => `${w.window} ${w.utilization}%`).join(' · ')}`);
|
|
1333
|
+
}
|
|
1334
|
+
catch { /* bookkeeping must never interrupt work */ }
|
|
1335
|
+
};
|
|
1336
|
+
/** Every plan, one after another — each read starts a Claude process, so not all at once. */
|
|
1337
|
+
const measureAll = async () => {
|
|
1338
|
+
const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { }).catch(() => null);
|
|
1339
|
+
if (!subs)
|
|
1340
|
+
return;
|
|
1341
|
+
const plans = await subs.measure().catch(() => []);
|
|
1342
|
+
// Said, because a plan missing here — a revoked token — is otherwise invisible.
|
|
1343
|
+
log('tool', `claude usage: measuring ${plans.length ? plans.map((p) => p.label).join(', ') : 'nothing (no readable subscription tokens)'}`);
|
|
1344
|
+
for (const plan of plans)
|
|
1345
|
+
await measurePlan(subs, plan, true);
|
|
1346
|
+
};
|
|
1347
|
+
void measureAll();
|
|
1348
|
+
// The walls, checked once at start — before any card is picked up. A restart while paused
|
|
1349
|
+
// picks the pause back up from FlareDeck rather than re-announcing it.
|
|
1350
|
+
void (async () => {
|
|
1351
|
+
const server = await opts.deck?.pauseState().catch(() => undefined);
|
|
1352
|
+
if (server) {
|
|
1353
|
+
paused = { check: server.check, reason: server.reason };
|
|
1354
|
+
readyReported = !!server.ready;
|
|
1355
|
+
state.paused = server.reason;
|
|
1356
|
+
log('warn', `PAUSED since ${server.since.slice(0, 16).replace('T', ' ')} (${server.check}) — doing nothing until the lead resumes me`);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const wall = await walls();
|
|
1360
|
+
if (wall)
|
|
1361
|
+
await enterPause(wall);
|
|
1362
|
+
})();
|
|
885
1363
|
const sweepNow = () => {
|
|
886
|
-
if (!sweepsEnabled || stopping)
|
|
1364
|
+
if (paused || !sweepsEnabled || stopping)
|
|
887
1365
|
return;
|
|
888
1366
|
// Busy is reason enough to wait: the next tick is minutes away and the work is not going
|
|
889
|
-
// anywhere.
|
|
890
|
-
if (working || queue.length
|
|
1367
|
+
// anywhere.
|
|
1368
|
+
if (working || queue.length)
|
|
891
1369
|
return;
|
|
892
|
-
|
|
893
|
-
|
|
1370
|
+
void (async () => {
|
|
1371
|
+
/* A card already in progress IS the answer to "what next" — no model needed. Asking the
|
|
1372
|
+
model instead had it try to start a second card every sweep, be refused by FlareDeck's
|
|
1373
|
+
one-card rule, say "the harness will pick up KAN-0074", and stop — for an hour, because
|
|
1374
|
+
nothing did. So the harness resumes it first, and only sweeps when nothing is held. */
|
|
1375
|
+
if (await resumeOwnWork())
|
|
1376
|
+
return;
|
|
1377
|
+
// `live.size` covers a card whose session is parked between events.
|
|
1378
|
+
if (working || queue.length || live.size)
|
|
1379
|
+
return;
|
|
1380
|
+
if (enqueue({ id: 'sweep', event: 'sweep', line: 'checking what is still assigned to you' }))
|
|
1381
|
+
void drain();
|
|
1382
|
+
})();
|
|
894
1383
|
};
|
|
895
1384
|
/**
|
|
896
1385
|
* A card of mine already in progress, picked up again without being asked.
|
|
@@ -905,21 +1394,46 @@ export async function serve(opts) {
|
|
|
905
1394
|
* have been — with a worktree, a branch, and everything else a card turn gets.
|
|
906
1395
|
*/
|
|
907
1396
|
const resumeOwnWork = async () => {
|
|
908
|
-
|
|
909
|
-
|
|
1397
|
+
/* Not `live.size`: a session kept WARM for a follow-up is a cache, not work, and blocking on
|
|
1398
|
+
it meant the card in progress was never resumed within fifteen minutes of any turn. */
|
|
1399
|
+
if (paused || !sweepsEnabled || stopping || working || queue.length)
|
|
1400
|
+
return false;
|
|
910
1401
|
let mine = [];
|
|
911
1402
|
try {
|
|
912
|
-
|
|
1403
|
+
// Scoped, like every other account-wide question. This one is asked by the HARNESS rather
|
|
1404
|
+
// than by the model, so the proxy that narrows the model's calls never saw it — and a
|
|
1405
|
+
// board-scoped agent went straight back to picking up another team's cards: KD-1361, then
|
|
1406
|
+
// KD-1362, from a board nobody had ticked. The card then 404s, because it is not on a
|
|
1407
|
+
// board this agent works.
|
|
1408
|
+
const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', { ...(boardArg([...myBoards]) !== undefined ? { board: boardArg([...myBoards]) } : {}) });
|
|
913
1409
|
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
914
|
-
//
|
|
915
|
-
|
|
1410
|
+
// Read from the fields the answer prints, and filtered here as well as asked for — the
|
|
1411
|
+
// server is deployed separately from this, so the scope cannot depend on its version.
|
|
1412
|
+
// In the order it names them: priority order, which is the board's.
|
|
1413
|
+
mine = outstandingCards(text)
|
|
1414
|
+
.filter((c) => onMyBoards(myBoards, c))
|
|
1415
|
+
.map((c) => ({ code: c.code }));
|
|
916
1416
|
}
|
|
917
1417
|
catch {
|
|
918
|
-
return; // unreachable board is the stream's problem to report, not this timer's
|
|
1418
|
+
return false; // unreachable board is the stream's problem to report, not this timer's
|
|
1419
|
+
}
|
|
1420
|
+
/* IN PROGRESS by the lane's TYPE — the same test FlareDeck's one-card rule uses. This took the
|
|
1421
|
+
first outstanding card in priority order whatever its lane, so it picked KAN-0080 out of
|
|
1422
|
+
Review while KAN-0074 sat in "In dev" untouched: the model was told "the harness picks up
|
|
1423
|
+
the card in progress" and the harness picked a different one, and the agent went round
|
|
1424
|
+
that loop for an hour. Lane NAMES differ per board ("In dev", "Doing"); the type does not. */
|
|
1425
|
+
let next;
|
|
1426
|
+
for (const c of mine) {
|
|
1427
|
+
if (sessions.has(c.code) || live.has(c.code))
|
|
1428
|
+
continue;
|
|
1429
|
+
const card = await opts.deck?.card(c.code).catch(() => null);
|
|
1430
|
+
if (String(card?.lane?.type ?? '').toLowerCase() === 'inprogress') {
|
|
1431
|
+
next = c;
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
919
1434
|
}
|
|
920
|
-
const next = mine.find((c) => !sessions.has(c.code) && !live.has(c.code));
|
|
921
1435
|
if (!next)
|
|
922
|
-
return;
|
|
1436
|
+
return false;
|
|
923
1437
|
log('tool', `picking ${next.code} back up — it is yours and already in progress`);
|
|
924
1438
|
if (enqueue({
|
|
925
1439
|
id: `resume-${next.code}-${Date.now()}`,
|
|
@@ -929,6 +1443,7 @@ export async function serve(opts) {
|
|
|
929
1443
|
cardKey: next.code,
|
|
930
1444
|
}))
|
|
931
1445
|
void drain();
|
|
1446
|
+
return true;
|
|
932
1447
|
};
|
|
933
1448
|
const sweepTimer = setInterval(sweepNow, SWEEP_EVERY_MS);
|
|
934
1449
|
sweepTimer.unref();
|
|
@@ -1014,6 +1529,8 @@ export async function serve(opts) {
|
|
|
1014
1529
|
status.startCard(current, !!event.cardKey);
|
|
1015
1530
|
void report();
|
|
1016
1531
|
let didWork = true;
|
|
1532
|
+
/** Set when the turn failed on its Claude plan, not its work: run it again at once. */
|
|
1533
|
+
let switchPlan = false;
|
|
1017
1534
|
turnCwd = '';
|
|
1018
1535
|
try {
|
|
1019
1536
|
await handle(event);
|
|
@@ -1022,13 +1539,30 @@ export async function serve(opts) {
|
|
|
1022
1539
|
// One bad event must not take the daemon down — that is the failure it exists to fix.
|
|
1023
1540
|
didWork = !(e instanceof CouldNotWork);
|
|
1024
1541
|
log('warn', `${current}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1542
|
+
if (e instanceof RetryOnAnotherPlan)
|
|
1543
|
+
switchPlan = true;
|
|
1025
1544
|
}
|
|
1026
1545
|
// Acknowledged only after the work, and only if the work could happen at all. A crash
|
|
1027
1546
|
// mid-turn redelivers; so does a turn that never had what it needed.
|
|
1028
1547
|
status.finishCard(didWork);
|
|
1029
1548
|
void report();
|
|
1030
1549
|
pending.delete(event.id);
|
|
1031
|
-
|
|
1550
|
+
// The plan this event ran on has just moved: read it now, so the next `choose` and the
|
|
1551
|
+
// Secrets tab see where it stands. Not after a plan switch — that plan is filed already.
|
|
1552
|
+
// Skipped when the run already reported usage on its own — a separate read would cost a
|
|
1553
|
+
// probe request to learn what the run just said.
|
|
1554
|
+
if (turnPlan && !switchPlan && !turnSawUsage) {
|
|
1555
|
+
const plan = turnPlan;
|
|
1556
|
+
void subscriptionsForAgent(config.mcpUrl, () => opts.auth(), () => { })
|
|
1557
|
+
.then((subs) => measurePlan(subs, plan))
|
|
1558
|
+
.catch(() => { });
|
|
1559
|
+
}
|
|
1560
|
+
turnPlan = null;
|
|
1561
|
+
turnSawUsage = false;
|
|
1562
|
+
// Before the ask/sweep branch: a sweep that hit a dead plan is retried like any other event.
|
|
1563
|
+
if (switchPlan)
|
|
1564
|
+
enqueue(event, true);
|
|
1565
|
+
else if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
|
|
1032
1566
|
else if (didWork)
|
|
1033
1567
|
await stream.ack([event.id]);
|
|
1034
1568
|
else {
|
|
@@ -1049,6 +1583,13 @@ export async function serve(opts) {
|
|
|
1049
1583
|
// soon — and then taken down, so a day of cards does not leave a day of servers running.
|
|
1050
1584
|
if (turnCwd && turnCwd !== config.repo)
|
|
1051
1585
|
scheduleEnvironmentStop(turnCwd);
|
|
1586
|
+
// The environment is kept warm; the browser is not. It holds a Chromium tree, the next
|
|
1587
|
+
// turn builds its own, and a page left open is not something a later turn can reach.
|
|
1588
|
+
if (turnEyes) {
|
|
1589
|
+
const e = turnEyes;
|
|
1590
|
+
turnEyes = null;
|
|
1591
|
+
await e.close().catch(() => { });
|
|
1592
|
+
}
|
|
1052
1593
|
current = '';
|
|
1053
1594
|
delete state.plan;
|
|
1054
1595
|
state.working = '';
|
|
@@ -1062,9 +1603,15 @@ export async function serve(opts) {
|
|
|
1062
1603
|
finally {
|
|
1063
1604
|
working = false;
|
|
1064
1605
|
}
|
|
1065
|
-
//
|
|
1066
|
-
// once, rather than leaving them to notice.
|
|
1067
|
-
|
|
1606
|
+
// An empty queue at the end of a drain means nothing is running and nothing is waiting: this
|
|
1607
|
+
// agent has nothing to do. Tell its lead, once, rather than leaving them to notice.
|
|
1608
|
+
//
|
|
1609
|
+
// This used to require `!live.size` as well, which reads like "nothing running" and is not.
|
|
1610
|
+
// `live` holds sessions kept WARM for fifteen minutes so a follow-up on the same card costs
|
|
1611
|
+
// a resume instead of a re-orientation — a cache, not work. So every agent went quiet for
|
|
1612
|
+
// fifteen minutes after finishing anything, which is exactly the window in which it most
|
|
1613
|
+
// needs a new card. Two sat idle and silent while eleven cards waited.
|
|
1614
|
+
if (!queue.length)
|
|
1068
1615
|
void offerToLead();
|
|
1069
1616
|
}
|
|
1070
1617
|
/**
|
|
@@ -1153,7 +1700,59 @@ export async function serve(opts) {
|
|
|
1153
1700
|
* needs-human" over the top of it. An agent said it had asked for help, a person was never told,
|
|
1154
1701
|
* and the card sat.
|
|
1155
1702
|
*/
|
|
1156
|
-
|
|
1703
|
+
/**
|
|
1704
|
+
* Put a local file on a card, done here rather than by the agent's shell. The upload is a PUT of
|
|
1705
|
+
* bytes to FlareDeck's own upload address, and the auto-mode classifier refused it as
|
|
1706
|
+
* "destructive, reaching production" — so an agent with seven screenshots asked a person to
|
|
1707
|
+
* upload them. Same three steps the tools describe (mint a link, PUT, wire it in), no curl.
|
|
1708
|
+
*/
|
|
1709
|
+
const attachFile = async (card, path, comment) => {
|
|
1710
|
+
const { existsSync, readFileSync, realpathSync, statSync } = await import('node:fs');
|
|
1711
|
+
const { basename, extname, isAbsolute, resolve } = await import('node:path');
|
|
1712
|
+
const { tmpdir } = await import('node:os');
|
|
1713
|
+
const full = isAbsolute(path) ? path : resolve(card.cwd, path);
|
|
1714
|
+
if (!existsSync(full))
|
|
1715
|
+
throw new Error(`there is no file at ${full}`);
|
|
1716
|
+
const real = realpathSync(full);
|
|
1717
|
+
const roots = [card.cwd, '/tmp', '/private/tmp', tmpdir()].map((r) => { try {
|
|
1718
|
+
return realpathSync(r);
|
|
1719
|
+
}
|
|
1720
|
+
catch {
|
|
1721
|
+
return r;
|
|
1722
|
+
} });
|
|
1723
|
+
if (!roots.some((r) => real === r || real.startsWith(`${r}/`)))
|
|
1724
|
+
throw new Error('only files inside your checkout or /tmp can be attached');
|
|
1725
|
+
const size = statSync(real).size;
|
|
1726
|
+
if (size > 30 * 1024 * 1024)
|
|
1727
|
+
throw new Error(`the file is ${Math.round(size / 1048576)} MB; the limit is 30 MB`);
|
|
1728
|
+
const types = {
|
|
1729
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
1730
|
+
'.pdf': 'application/pdf', '.csv': 'text/csv', '.txt': 'text/plain', '.log': 'text/plain', '.json': 'application/json',
|
|
1731
|
+
'.md': 'text/markdown', '.html': 'text/html', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
1732
|
+
};
|
|
1733
|
+
const contentType = types[extname(real).toLowerCase()] ?? 'application/octet-stream';
|
|
1734
|
+
const ids = { card_id: card.code || card.card, ...(card.board ? { board_id: card.board } : {}) };
|
|
1735
|
+
const minted = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-attachment-upload-url', {
|
|
1736
|
+
...ids, file_name: basename(real), content_type: contentType,
|
|
1737
|
+
});
|
|
1738
|
+
const uploadUrl = /https?:\/\/[^\s"`]+\/upload\/[^\s"`]+/.exec(minted)?.[0];
|
|
1739
|
+
const objectPath = /`object_path`: `([^`]+)`/.exec(minted)?.[1];
|
|
1740
|
+
if (!uploadUrl || !objectPath)
|
|
1741
|
+
throw new Error('FlareDeck did not return an upload address');
|
|
1742
|
+
const put = await fetch(uploadUrl, { method: 'PUT', body: readFileSync(real), headers: { 'Content-Type': contentType } });
|
|
1743
|
+
if (!put.ok)
|
|
1744
|
+
throw new Error(`the upload was refused (${put.status})`);
|
|
1745
|
+
const wired = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'attach-uploaded-file', {
|
|
1746
|
+
...ids, object_path: objectPath, attach_to: 'comment', ...(comment ? { comment } : {}),
|
|
1747
|
+
});
|
|
1748
|
+
log('tool', `attached ${basename(real)} to ${card.code || card.card}`);
|
|
1749
|
+
return `Attached ${basename(real)} to ${card.code || card.card}. ${wired.split('\n')[0]}`;
|
|
1750
|
+
};
|
|
1751
|
+
const flagCard = async (ref, reason, kind,
|
|
1752
|
+
/** The one-line ask and its answer buttons — what the person actually sees on a phone. */
|
|
1753
|
+
ask,
|
|
1754
|
+
/** Somewhere for the person to GO, which is what a sign-in request is mostly made of. */
|
|
1755
|
+
links) => {
|
|
1157
1756
|
const label = ref.code || ref.card || 'card';
|
|
1158
1757
|
try {
|
|
1159
1758
|
const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'set-needs-human', {
|
|
@@ -1162,6 +1761,8 @@ export async function serve(opts) {
|
|
|
1162
1761
|
...(ref.board ? { board_id: ref.board } : {}),
|
|
1163
1762
|
reason,
|
|
1164
1763
|
...(kind ? { kind } : {}),
|
|
1764
|
+
...(ask ? { question: ask.question, options: ask.options } : {}),
|
|
1765
|
+
...(links?.length ? { links } : {}),
|
|
1165
1766
|
});
|
|
1166
1767
|
if (out.trimStart().startsWith('\u274c') || out.trimStart().startsWith('\ud83d\udeab')) {
|
|
1167
1768
|
log('warn', `${label}: FlareDeck did not flag this card \u2014 ${out.split('\n')[0]}`);
|
|
@@ -1218,7 +1819,7 @@ export async function serve(opts) {
|
|
|
1218
1819
|
log('event', `${event.cardCode || event.cardKey}: comment on a finished card — nothing to do`);
|
|
1219
1820
|
return;
|
|
1220
1821
|
}
|
|
1221
|
-
|
|
1822
|
+
let key = event.cardKey || (event.event === 'ask' ? 'ask' : event.event === 'sweep' ? 'sweep' : event.event === 'scheduled' ? `schedule-${event.scheduleId}` : 'inbox');
|
|
1222
1823
|
let turnTokens = 0;
|
|
1223
1824
|
let nudged = false;
|
|
1224
1825
|
// A checkout of its own per card, so it cannot edit a working tree someone else is in.
|
|
@@ -1307,9 +1908,42 @@ export async function serve(opts) {
|
|
|
1307
1908
|
// the publishing tools and the journal. Without this, HAL committed its work on a message
|
|
1308
1909
|
// turn, was told to publish with push_branch, and went looking for push_branch on the
|
|
1309
1910
|
// filesystem: the harness had named a tool that turn did not have.
|
|
1911
|
+
// Room on the disk before a card gets a checkout. An agent left alone overnight filled one to
|
|
1912
|
+
// 762 MB free, and from there nothing works and nothing says why: Docker cannot make a
|
|
1913
|
+
// container, builds die half-written, the machine swaps. Below LOW the idle worktrees go first
|
|
1914
|
+
// (sooner than usual); below MIN the card is not started, and a person is told what to clear.
|
|
1915
|
+
if (event.cardKey && willChange && (chosen || where.kind === 'none')) {
|
|
1916
|
+
let free = freeDiskBytes(repo);
|
|
1917
|
+
if (free !== null && free < DISK_LOW) {
|
|
1918
|
+
await sweepWorktrees(repo, WORKTREE_IDLE_LOW_DISK_MS, (t) => log('tool', t), activeTrees()).catch(() => { });
|
|
1919
|
+
free = freeDiskBytes(repo);
|
|
1920
|
+
}
|
|
1921
|
+
if (free !== null && free < DISK_MIN) {
|
|
1922
|
+
const wall = await walls();
|
|
1923
|
+
if (wall) {
|
|
1924
|
+
await enterPause(wall);
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1310
1929
|
const tree = event.cardKey && (chosen || where.kind === 'none')
|
|
1311
1930
|
? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
|
|
1312
|
-
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
|
|
1931
|
+
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t), (code) => ensureWorktree(repo, code, (t) => log('tool', t), startFrom, config.worktreeLocalFiles));
|
|
1932
|
+
/**
|
|
1933
|
+
* Questions about a CARD get their own session, not the shared one.
|
|
1934
|
+
*
|
|
1935
|
+
* Every pane ask used the single key 'ask', and a session keeps the tools it was built with.
|
|
1936
|
+
* So the first question asked before a card had a checkout built a session with no browser
|
|
1937
|
+
* and no publishing tools — and every later question about that card resumed it and
|
|
1938
|
+
* inherited the lack. The agent reported it precisely ("the browser tools aren't mounted
|
|
1939
|
+
* this session") and went looking for headless Chrome in the shell instead, which is both
|
|
1940
|
+
* worse and the kind of workaround the guards exist to stop.
|
|
1941
|
+
*
|
|
1942
|
+
* Keyed on the checkout, so a question about a card shares a session with other questions
|
|
1943
|
+
* about THAT card and nothing else.
|
|
1944
|
+
*/
|
|
1945
|
+
if (event.event === 'ask' && tree)
|
|
1946
|
+
key = `ask:${tree.path}`;
|
|
1313
1947
|
const cwd = tree?.path ?? repo;
|
|
1314
1948
|
// Back on this checkout: whatever was waiting to take its environment down can stand down.
|
|
1315
1949
|
turnCwd = cwd;
|
|
@@ -1321,6 +1955,7 @@ export async function serve(opts) {
|
|
|
1321
1955
|
// So a later message naming this card by its CODE can find the work. See rememberWorktree.
|
|
1322
1956
|
if (tree && (event.cardCode || event.cardKey)) {
|
|
1323
1957
|
rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
|
|
1958
|
+
currentTree = tree.path;
|
|
1324
1959
|
}
|
|
1325
1960
|
// No worktree, but this turn is going to change something. That used to carry on in whatever
|
|
1326
1961
|
// directory happened to be the fallback — no branch, no isolation, and no word said. An agent
|
|
@@ -1416,7 +2051,7 @@ export async function serve(opts) {
|
|
|
1416
2051
|
tag = code;
|
|
1417
2052
|
state.working = code;
|
|
1418
2053
|
};
|
|
1419
|
-
const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
|
|
2054
|
+
const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar, [...myBoards]);
|
|
1420
2055
|
const held = live.get(key);
|
|
1421
2056
|
if (held?.timer)
|
|
1422
2057
|
clearTimeout(held.timer);
|
|
@@ -1448,6 +2083,19 @@ export async function serve(opts) {
|
|
|
1448
2083
|
if (!resume && event.cardKey && event.event !== 'review' && event.event !== 'ask') {
|
|
1449
2084
|
await startOnBoard(event.cardCode || event.cardKey);
|
|
1450
2085
|
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Which card this turn is about — including when nobody dispatched it.
|
|
2088
|
+
*
|
|
2089
|
+
* This was set only from the event's own cardKey, so a question typed in the pane about a
|
|
2090
|
+
* card had none: the agent had the card, its checkout and its environment, hit a sign-in it
|
|
2091
|
+
* could not pass, and was told "there is no card being worked, so there is nobody to ask
|
|
2092
|
+
* about it". It fell back to leaving a comment, which nobody is notified about. The one
|
|
2093
|
+
* moment the escalation exists for, and it was unavailable.
|
|
2094
|
+
*
|
|
2095
|
+
* `tag` is the code the turn resolved to — from the event, or from the card a pane question
|
|
2096
|
+
* named. FlareDeck takes a human code wherever it takes a card id, so the code alone is
|
|
2097
|
+
* enough to flag, comment and clear.
|
|
2098
|
+
*/
|
|
1451
2099
|
onCard = event.cardKey
|
|
1452
2100
|
? {
|
|
1453
2101
|
card: event.cardKey,
|
|
@@ -1455,7 +2103,9 @@ export async function serve(opts) {
|
|
|
1455
2103
|
...(event.boardKey ? { board: event.boardKey } : {}),
|
|
1456
2104
|
...(event.cardCode ? { code: event.cardCode } : {}),
|
|
1457
2105
|
}
|
|
1458
|
-
:
|
|
2106
|
+
: tag
|
|
2107
|
+
? { card: tag, cwd, code: tag, ...(event.boardKey ? { board: event.boardKey } : {}) }
|
|
2108
|
+
: null;
|
|
1459
2109
|
job = tree
|
|
1460
2110
|
? {
|
|
1461
2111
|
cwd,
|
|
@@ -1464,7 +2114,13 @@ export async function serve(opts) {
|
|
|
1464
2114
|
// back to the repo's default branch when it has not.
|
|
1465
2115
|
base: startFrom ?? (await baseBranch(cwd)),
|
|
1466
2116
|
card: event.cardCode || key,
|
|
2117
|
+
onAuthFailure: (why) => {
|
|
2118
|
+
void enterPause({ check: 'github', reason: `GitHub refused this machine's login when I pushed (${why.slice(0, 160)}), so nothing I write can be published. Somebody signs it in again on this machine: \`flaredeck-agent login --github-token ghp_…\` (a classic token with repo and workflow scope does not expire).` });
|
|
2119
|
+
},
|
|
1467
2120
|
token: async () => {
|
|
2121
|
+
// Read fresh at every push: the login is the machine's, so signing in again from
|
|
2122
|
+
// any pane fixes an agent that has been running all day with the old one.
|
|
2123
|
+
config.github = loadGitHub() ?? config.github;
|
|
1468
2124
|
if (!config.github)
|
|
1469
2125
|
throw new Error('This machine has no GitHub login — run flaredeck-agent login.');
|
|
1470
2126
|
return freshGitHubToken(config.github, (next) => {
|
|
@@ -1492,7 +2148,7 @@ export async function serve(opts) {
|
|
|
1492
2148
|
// servers before it was allowed to look at a stylesheet. An agent that finds it does need
|
|
1493
2149
|
// something running can start it itself; the brief says how.
|
|
1494
2150
|
const needsRunning = (prepared?.jev?.needs_running ?? 0) >= 0.5;
|
|
1495
|
-
|
|
2151
|
+
let env = forACard && needsRunning
|
|
1496
2152
|
// The script this board declared for the repository this card is in, if it declared one.
|
|
1497
2153
|
// Matched on the remote, the same way the base branch is.
|
|
1498
2154
|
? await startEnvironment(cwd, (t) => log('tool', t), where.kind === 'repo' ? where.script : undefined)
|
|
@@ -1500,10 +2156,259 @@ export async function serve(opts) {
|
|
|
1500
2156
|
// The guard is built once at startup; what this agent can reach changes per card. Handing it
|
|
1501
2157
|
// the live picture is the difference between judging "$DATABASE_URL" as a scratch database
|
|
1502
2158
|
// and judging it as a live one.
|
|
2159
|
+
// Deployed addresses the board declared for this card's repository — the dev or UAT site. A
|
|
2160
|
+
// card is usually about code that is already live somewhere, and an agent that can only see
|
|
2161
|
+
// what it started locally cannot tell its own change from how the thing already behaved.
|
|
2162
|
+
const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
|
|
2163
|
+
// Which started part is the app itself — asked of the parts, since boards rarely name them.
|
|
2164
|
+
const web = Object.keys(env.endpoints).length ? await webPart(env.endpoints).catch(() => undefined) : undefined;
|
|
2165
|
+
if (web)
|
|
2166
|
+
log('tool', `browser: "${web}" serves the app — open_page opens it by default`);
|
|
2167
|
+
/**
|
|
2168
|
+
* Starting the project, as something the agent can ASK FOR.
|
|
2169
|
+
*
|
|
2170
|
+
* It was decided once, before the turn, by a classifier not even consulted for a question
|
|
2171
|
+
* typed in the pane — so the answer defaulted to "no" however plainly the card said
|
|
2172
|
+
* otherwise, and the agent had to find the script and start it detached by hand. Now it can
|
|
2173
|
+
* ask, and a failure hands back the error together with what it may do about it.
|
|
2174
|
+
*/
|
|
2175
|
+
const envTools = environmentTools({
|
|
2176
|
+
cwd: () => cwd,
|
|
2177
|
+
script: () => (where.kind === 'repo' ? where.script : undefined),
|
|
2178
|
+
running: () => (env.ran ? env : null),
|
|
2179
|
+
// The same deployed addresses the browser gets, so a sign-in can be asked for the dev or
|
|
2180
|
+
// UAT site and not only for something this card started.
|
|
2181
|
+
sites: () => declaredSites,
|
|
2182
|
+
// Straight onto the live picture: the browser reads its addresses through a getter, so
|
|
2183
|
+
// open_page works the moment this returns — no new turn, nothing to re-read.
|
|
2184
|
+
onStarted: (started) => {
|
|
2185
|
+
env = started;
|
|
2186
|
+
reachable = { cwd, endpoints: env.endpoints };
|
|
2187
|
+
},
|
|
2188
|
+
onNote: (t) => log('tool', t),
|
|
2189
|
+
/**
|
|
2190
|
+
* Turning "ask somebody" into something that actually asks.
|
|
2191
|
+
*
|
|
2192
|
+
* Three pieces already worked and were never joined up: open_page DETECTS a sign-in wall
|
|
2193
|
+
* and says which kind it is, signInSession captures a session a person completes by hand,
|
|
2194
|
+
* and loadSession replays it for every later card. The gap was the middle. All wallBrief
|
|
2195
|
+
* could do was tell the agent in prose to ask, with no tool to call — so the agent wrote a
|
|
2196
|
+
* comment, flagged the card and stopped. That was the only thing available to it, and it
|
|
2197
|
+
* read like a model that would not try.
|
|
2198
|
+
*
|
|
2199
|
+
* So: start the viewer, flag the card with the address (a flag is what reaches a phone),
|
|
2200
|
+
* and when the session lands, store it, clear the flag and put the card back at the front
|
|
2201
|
+
* of the queue. The agent's next turn opens the app already signed in.
|
|
2202
|
+
*
|
|
2203
|
+
* The viewer is LOOPBACK, deliberately: it drives a browser part-way through somebody's
|
|
2204
|
+
* sign-in, which is not a thing to publish on a reachable port. So the person has to be at
|
|
2205
|
+
* this machine. That is the case this is for — an agent testing localhost on a developer's
|
|
2206
|
+
* own box — and for a cloud agent it is a stated limit rather than a silent failure.
|
|
2207
|
+
*/
|
|
2208
|
+
...(onCard
|
|
2209
|
+
? {
|
|
2210
|
+
requestSignIn: async ({ part, url, why }) => {
|
|
2211
|
+
const card = onCard;
|
|
2212
|
+
if (!card)
|
|
2213
|
+
return { asked: false, why: 'the card went away while this was being asked' };
|
|
2214
|
+
if (!(await ensureBrowser((t) => log('tool', `browser: ${t}`)))) {
|
|
2215
|
+
return { asked: false, why: 'this machine has no browser and could not fetch one' };
|
|
2216
|
+
}
|
|
2217
|
+
const { chromium } = await import('playwright-core');
|
|
2218
|
+
const { signInSession } = await import('./signin.js');
|
|
2219
|
+
const { rememberSignInNow, HEADLESS_FULL } = await import('./browser.js');
|
|
2220
|
+
const { randomBytes } = await import('node:crypto');
|
|
2221
|
+
/* Through FlareDeck, not through a port on this machine. The person may be on a
|
|
2222
|
+
phone and the agent may be on a cloud box, which is the case this exists for;
|
|
2223
|
+
loopback only ever served the one where they are in the same room. */
|
|
2224
|
+
const id = `sgn_${randomBytes(9).toString('base64url')}`;
|
|
2225
|
+
/* agent-service, not the MCP. The relay was first built onto flaredeck-mcp, which
|
|
2226
|
+
cannot host it: the MCP deploys as a Firebase Function, and a Function hands you
|
|
2227
|
+
(req, res) with no http.Server underneath, so a WebSocket upgrade can never
|
|
2228
|
+
complete there. agent-service is a real Cloud Run service and already the one
|
|
2229
|
+
this harness holds an event stream open to. */
|
|
2230
|
+
const serviceUrl = process.env['FLAREDECK_RELAY_URL'] ?? config.agentServiceUrl;
|
|
2231
|
+
if (!serviceUrl) {
|
|
2232
|
+
// Configured by default, so this means somebody blanked it. Say which setting.
|
|
2233
|
+
return { asked: false, why: 'this harness has no agentServiceUrl, so a sign-in cannot be relayed' };
|
|
2234
|
+
}
|
|
2235
|
+
const relayBase = serviceUrl.replace(/^http/, 'ws').replace(/\/+$/, '');
|
|
2236
|
+
const appBase = (process.env['FLAREDECK_APP_URL'] ?? 'https://app.flaredeck.app').replace(/\/+$/, '');
|
|
2237
|
+
const viewerUrl = (t) => `${appBase}/signin/${id}?t=${encodeURIComponent(t)}`;
|
|
2238
|
+
/* Resolve as far as the ADDRESS and no further. Waiting for the sign-in itself would
|
|
2239
|
+
hold the agent's tool call open for as long as a person takes to get to their
|
|
2240
|
+
machine, and not waiting is the whole point: the card parks, the turn ends. */
|
|
2241
|
+
let settle;
|
|
2242
|
+
const ready = new Promise((r) => { settle = r; });
|
|
2243
|
+
const ref = {
|
|
2244
|
+
card: card.card,
|
|
2245
|
+
...(card.board ? { board: card.board } : {}),
|
|
2246
|
+
...(card.code ? { code: card.code } : {}),
|
|
2247
|
+
};
|
|
2248
|
+
void signInSession({
|
|
2249
|
+
url,
|
|
2250
|
+
launch: () => chromium.launch(HEADLESS_FULL),
|
|
2251
|
+
relay: { url: relayBase, token: await opts.auth(), id, viewerUrl },
|
|
2252
|
+
onReady: (viewer) => {
|
|
2253
|
+
log('tool', `sign-in wanted for "${part}" — ${viewer}`);
|
|
2254
|
+
void flagCard(ref, `${url} needs someone to sign in before this card can go on. ${why}\n\n` +
|
|
2255
|
+
`Open the sign-in below and do it there. It is this agent's real browser, ` +
|
|
2256
|
+
`shown live — click and type in the picture, then press Save & finish. The ` +
|
|
2257
|
+
`session is kept for the whole project, so this is once, not once per card.`, 'sign_in', undefined, [{ url: viewer, label: 'Sign in' }]);
|
|
2258
|
+
settle({ asked: true, where: viewer });
|
|
2259
|
+
},
|
|
2260
|
+
onNote: (t) => log('tool', `sign-in: ${t}`),
|
|
2261
|
+
})
|
|
2262
|
+
.then(async (r) => {
|
|
2263
|
+
if (!r.ok || !r.session) {
|
|
2264
|
+
log('warn', `sign-in for "${part}" did not finish: ${r.why ?? 'nothing was saved'}`);
|
|
2265
|
+
/* Say so ON THE CARD. The flag already raised carries a link to a viewer
|
|
2266
|
+
that is now dead, and a person coming back to it an hour later would find
|
|
2267
|
+
a page that cannot connect and no indication why — which reads as the
|
|
2268
|
+
feature being broken rather than as a window having closed. */
|
|
2269
|
+
void flagCard(ref, `The sign-in for ${url} expired before anyone did it — the link in the ` +
|
|
2270
|
+
`earlier request no longer works.\n\n${why}\n\nNothing is broken: ask ` +
|
|
2271
|
+
`the agent to try again and a fresh sign-in is offered.`, 'sign_in');
|
|
2272
|
+
settle({ asked: false, why: r.why ?? 'nothing was saved' });
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
/* Awaited, and the failure is handled. Sessions live only in FlareDeck now —
|
|
2276
|
+
there is no copy on this machine — so a failed write means the sign-in is
|
|
2277
|
+
GONE. Announcing "Signed in" and resuming the card would wake an agent that
|
|
2278
|
+
meets the same wall and asks again: a loop that reads as the feature being
|
|
2279
|
+
broken rather than as one write having failed. */
|
|
2280
|
+
const saved = await rememberSignInNow(config.repo, part, r.session, (t) => log('tool', `sign-in: ${t}`));
|
|
2281
|
+
if (!saved.ok) {
|
|
2282
|
+
log('warn', `sign-in for "${part}" completed but was NOT saved: ${saved.message ?? 'no reason given'}`);
|
|
2283
|
+
void flagCard(ref, `Someone signed in to ${url}, but the session could not be saved to FlareDeck ` +
|
|
2284
|
+
`(${saved.message ?? 'no reason given'}). Credentials are kept only in FlareDeck, ` +
|
|
2285
|
+
`so nothing was retained here and the sign-in has been lost.\n\n${why}\n\n` +
|
|
2286
|
+
`Once FlareDeck is reachable, ask the agent to try again.`, 'sign_in');
|
|
2287
|
+
settle({ asked: false, why: saved.message ?? 'the session could not be saved' });
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const site = saved.id;
|
|
2291
|
+
log('tool', `signed in — kept for ${site}; every card on this project opens straight in`);
|
|
2292
|
+
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
|
|
2293
|
+
card_id: card.code || card.card,
|
|
2294
|
+
...(card.board ? { board_id: card.board } : {}),
|
|
2295
|
+
}).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
|
|
2296
|
+
parked.delete(card.card);
|
|
2297
|
+
resumeParked(card, `Signed in. The session is kept for ${site}, so open_page opens the app signed ` +
|
|
2298
|
+
`in from now on. Carry on with what you were going to check: ${why}`);
|
|
2299
|
+
})
|
|
2300
|
+
.catch((e) => {
|
|
2301
|
+
const why2 = e instanceof Error ? e.message : String(e);
|
|
2302
|
+
log('warn', `sign-in for "${part}" failed: ${why2}`);
|
|
2303
|
+
settle({ asked: false, why: why2 });
|
|
2304
|
+
});
|
|
2305
|
+
return ready;
|
|
2306
|
+
},
|
|
2307
|
+
}
|
|
2308
|
+
: {}),
|
|
2309
|
+
});
|
|
2310
|
+
/**
|
|
2311
|
+
* Always mounted. Mounting it only when something was already running is what cached a
|
|
2312
|
+
* stale toolset for a whole session: the first question about a card came before its
|
|
2313
|
+
* checkout existed, so that session had no browser, and every later question resumed it and
|
|
2314
|
+
* inherited the lack — the agent went looking for headless Chrome in the shell instead. An
|
|
2315
|
+
* agent with nothing to look at simply does not call these; the brief says what to do first.
|
|
2316
|
+
*/
|
|
2317
|
+
const eyes = turnEyes = browserTools({
|
|
2318
|
+
repo: config.repo,
|
|
2319
|
+
endpoints: () => env.endpoints,
|
|
2320
|
+
...(web ? { defaultPart: () => web } : {}),
|
|
2321
|
+
sites: () => declaredSites,
|
|
2322
|
+
contract: () => env.contract,
|
|
2323
|
+
cwd,
|
|
2324
|
+
onNote: (t) => log('tool', t),
|
|
2325
|
+
});
|
|
1503
2326
|
reachable = { cwd, endpoints: env.endpoints };
|
|
1504
|
-
|
|
1505
|
-
|
|
2327
|
+
/**
|
|
2328
|
+
* Say which it is, because they are not the same and the difference matters.
|
|
2329
|
+
*
|
|
2330
|
+
* "this card does not look like it needs one" was printed whenever the project was not
|
|
2331
|
+
* started — including when nothing had looked. Intake does not run for a question typed in
|
|
2332
|
+
* the pane, so `needs_running` defaulted to 0 and the agent was told a judgement had been
|
|
2333
|
+
* made about its card that had not been made at all.
|
|
2334
|
+
*/
|
|
2335
|
+
if (forACard && !needsRunning) {
|
|
2336
|
+
log('tool', prepared
|
|
2337
|
+
? 'environment not started — this card does not look like it needs one'
|
|
2338
|
+
: 'environment not started — nothing judged whether it is needed; call start_environment if it is');
|
|
2339
|
+
}
|
|
2340
|
+
// A lead's sweep is a JOB, not a nudge: clear the ready column by running the team. A nudge
|
|
2341
|
+
// got one look and "nothing to do"; a goal keeps the lead going, turn after turn, until the
|
|
2342
|
+
// board itself says it is done — proven by get-portfolio, not by the lead's own say-so.
|
|
2343
|
+
const leadGoal = isLead && event.event === 'sweep' && process.env['FLAREDECK_AGENT_LEAD_GOAL'] !== '0'
|
|
2344
|
+
? LEAD_SWEEP_GOAL
|
|
2345
|
+
: undefined;
|
|
2346
|
+
if (leadGoal)
|
|
2347
|
+
log('tool', 'sweep with a goal: clear the ready column by dispatching (≤8 turns)');
|
|
2348
|
+
/**
|
|
2349
|
+
* Which Claude subscription pays for this run.
|
|
2350
|
+
*
|
|
2351
|
+
* Three subscriptions and twenty-odd agents, with daily and weekly caps that trip at
|
|
2352
|
+
* different times, used to mean going round the machines logging in again. FlareDeck holds
|
|
2353
|
+
* the tokens and the ledger now and picks one per run.
|
|
2354
|
+
*
|
|
2355
|
+
* Not reaching FlareDeck is NOT the same as having nothing left: `machine` means carry on
|
|
2356
|
+
* with whatever this box is logged in as, exactly as before any of this existed. Only
|
|
2357
|
+
* `none` — every subscription genuinely spent or paused — stops the run, and it says when
|
|
2358
|
+
* one frees up rather than failing blankly.
|
|
2359
|
+
*/
|
|
2360
|
+
/* The MCP list, fresh for this session — and a required server that is not connected stops
|
|
2361
|
+
the work here, before a plan is spent on a card that cannot be done properly. */
|
|
2362
|
+
const missingMcp = await refreshMcp();
|
|
2363
|
+
if (missingMcp?.length) {
|
|
2364
|
+
await enterPause(mcpWallFor(missingMcp));
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
const subs = await subscriptionsForAgent(config.mcpUrl, () => opts.auth(), (t) => log('tool', t));
|
|
2368
|
+
const claudePlan = subs ? await subs.choose(failedPlans.get(event.id) ?? []) : null;
|
|
2369
|
+
if (claudePlan?.use === 'token') {
|
|
2370
|
+
log('tool', `claude: ${claudePlan.label}`);
|
|
2371
|
+
// On the identity line too, not only in the scrollback: the scrollback moves on, and
|
|
2372
|
+
// "which account is this agent spending" is a thing you glance at, not search for.
|
|
2373
|
+
state.claudeAccount = claudePlan.label;
|
|
2374
|
+
// Measured again once this event is done — see measurePlan.
|
|
2375
|
+
turnPlan = { id: claudePlan.id, label: claudePlan.label, token: claudePlan.token };
|
|
2376
|
+
}
|
|
2377
|
+
if (claudePlan?.use === 'none') {
|
|
2378
|
+
const when = claudePlan.availableAt ? new Date(claudePlan.availableAt).toLocaleString() : 'later';
|
|
2379
|
+
const tried = failedPlans.get(event.id)?.length ?? 0;
|
|
2380
|
+
failedPlans.delete(event.id);
|
|
2381
|
+
/**
|
|
2382
|
+
* Spent, not paused: the harness knows to the minute when a plan comes back — FlareDeck's
|
|
2383
|
+
* answer carries the soonest reset — so it waits for it and carries on by itself. Nobody is
|
|
2384
|
+
* asked to do anything about a clock. Only a fleet with NO reset coming (every plan paused,
|
|
2385
|
+
* which only a person undoes) is flagged.
|
|
2386
|
+
*/
|
|
2387
|
+
if (claudePlan.availableAt && claudePlan.availableAt > Date.now()) {
|
|
2388
|
+
const at = claudePlan.availableAt;
|
|
2389
|
+
if (!waitingForPlan.has(event.id)) {
|
|
2390
|
+
// A little after the reset, so the first request is not refused for being a second early.
|
|
2391
|
+
const timer = setTimeout(() => {
|
|
2392
|
+
waitingForPlan.delete(event.id);
|
|
2393
|
+
log('event', `a Claude subscription is back — resuming ${event.cardCode || event.line}`);
|
|
2394
|
+
if (enqueue(event, true))
|
|
2395
|
+
void drain();
|
|
2396
|
+
}, at - Date.now() + 60_000);
|
|
2397
|
+
timer.unref?.();
|
|
2398
|
+
waitingForPlan.set(event.id, timer);
|
|
2399
|
+
}
|
|
2400
|
+
log('warn', `every Claude subscription is spent${tried ? ` (${tried} failed on this turn)` : ''} — waiting, resuming at ${new Date(at).toLocaleTimeString()}`);
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
log('warn', `every Claude subscription is spent or paused${tried ? ` (${tried} failed on this turn)` : ''} — the soonest frees up ${when}`);
|
|
2404
|
+
await flagCard({ card: event.cardKey ?? '', ...(event.boardKey ? { board: event.boardKey } : {}), ...(event.cardCode ? { code: event.cardCode } : {}) }, `I cannot start this card: every Claude subscription in FlareDeck is spent, paused` +
|
|
2405
|
+
`${tried ? ', or just failed on this card' : ''}. ` +
|
|
2406
|
+
`The soonest one frees up ${when}.\n\nNothing is wrong with the card. Add or unpause a ` +
|
|
2407
|
+
`subscription on the Integrations page, or wait for the window to reset.`, 'technical');
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
1506
2410
|
const engineContext = {
|
|
2411
|
+
...(leadGoal ? { goal: leadGoal } : {}),
|
|
1507
2412
|
config: { ...config, repo: cwd },
|
|
1508
2413
|
// Only a real worktree confines anything; without one nothing is enforced here, and the
|
|
1509
2414
|
// guard below makes sure a turn that would CHANGE code never gets that far.
|
|
@@ -1533,21 +2438,16 @@ export async function serve(opts) {
|
|
|
1533
2438
|
...(resume ? { resume } : {}),
|
|
1534
2439
|
...(prepared?.model ? { model: prepared.model } : {}),
|
|
1535
2440
|
...(prepared?.size ? { size: prepared.size } : {}),
|
|
2441
|
+
...(claudePlan?.use === 'token' ? { claudeToken: claudePlan.token } : {}),
|
|
1536
2442
|
tools: {
|
|
1537
2443
|
...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
|
|
1538
|
-
// Eyes,
|
|
1539
|
-
//
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
contract: () => env.contract,
|
|
1546
|
-
cwd,
|
|
1547
|
-
onNote: (t) => log('tool', t),
|
|
1548
|
-
}),
|
|
1549
|
-
}
|
|
1550
|
-
: {}),
|
|
2444
|
+
// Eyes, for any card. They used to be mounted only when the harness had itself started
|
|
2445
|
+
// something, which left an agent working alone unable to get them: no start script meant
|
|
2446
|
+
// no browser AND no mention of one, so it could not know that writing the script it is
|
|
2447
|
+
// perfectly able to write was the thing standing between it and seeing its own change.
|
|
2448
|
+
// The endpoints are read through a getter, so something the agent starts mid-turn counts.
|
|
2449
|
+
browser: eyes,
|
|
2450
|
+
env: envTools,
|
|
1551
2451
|
team: teamSet,
|
|
1552
2452
|
flaredeck,
|
|
1553
2453
|
runs: runsSet,
|
|
@@ -1568,7 +2468,7 @@ export async function serve(opts) {
|
|
|
1568
2468
|
freeWorkerBrief(event) +
|
|
1569
2469
|
// Only tell an agent about the environment when the card actually needs one running.
|
|
1570
2470
|
environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
|
|
1571
|
-
browserBrief(env.
|
|
2471
|
+
browserBrief(env.endpoints, declaredSites, forACard, web) +
|
|
1572
2472
|
(wrongEnv.length
|
|
1573
2473
|
? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
|
|
1574
2474
|
`Anything you test against it tells you about another card's code, and it will answer ` +
|
|
@@ -1580,82 +2480,149 @@ export async function serve(opts) {
|
|
|
1580
2480
|
liveRun = run;
|
|
1581
2481
|
// An agent that has started fighting the tools is not going to stop on its own.
|
|
1582
2482
|
const struggling = watchForDesperation();
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
2483
|
+
/**
|
|
2484
|
+
* One cap is usually seen two or three times in a run: the structured `rate_limit_event`
|
|
2485
|
+
* (which knows the window and the reset), then the error on the assistant message and the
|
|
2486
|
+
* result text (which know neither). Reporting all of them filed an `unknown` window with a
|
|
2487
|
+
* guessed five-hour block on top of the real one — keeping a plan out hours past its actual
|
|
2488
|
+
* reset. Once a run has reported a named window, the anonymous echoes are dropped.
|
|
2489
|
+
*/
|
|
2490
|
+
let spentNamed = false;
|
|
2491
|
+
let spentAny = false;
|
|
2492
|
+
const spentReports = [];
|
|
2493
|
+
let doneText = '';
|
|
2494
|
+
let runError = null;
|
|
2495
|
+
try {
|
|
2496
|
+
for await (const out of run.events) {
|
|
2497
|
+
status.observe(out);
|
|
2498
|
+
if (out.kind === 'done' && !out.ok)
|
|
2499
|
+
doneText = String(out.result ?? '');
|
|
2500
|
+
if (out.kind === 'limits' && claudePlan?.use === 'token') {
|
|
2501
|
+
/* Reported, never interpreted here. 'allowed_warning' arrives while the plan is still
|
|
2502
|
+
working — treating it as spent would retire every subscription at about half capacity
|
|
2503
|
+
and leave the agents with nothing while each account still had headroom. */
|
|
2504
|
+
if (out.spent) {
|
|
2505
|
+
const named = !!out.window && out.window !== 'unknown';
|
|
2506
|
+
spentAny = true;
|
|
2507
|
+
if (named || !spentNamed) {
|
|
2508
|
+
spentNamed ||= named;
|
|
2509
|
+
spentReports.push(subs?.spent(claudePlan.id, out.window, out.resetsAt, out.why ?? `hit during ${event.cardCode ?? event.cardKey ?? 'a run'}`) ?? Promise.resolve());
|
|
2510
|
+
log('warn', `claude: ${claudePlan.label} hit its ${out.window ?? 'usage'} limit (${out.source ?? 'unknown signal'})`);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
else if (out.window && typeof out.utilization === 'number') {
|
|
2514
|
+
turnSawUsage = true;
|
|
2515
|
+
void subs?.seen(claudePlan.id, out.window, out.utilization, out.resetsAt);
|
|
1595
2516
|
}
|
|
1596
|
-
if (tag)
|
|
1597
|
-
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
1598
2517
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
// The plan replaces the previous one — TodoWrite always sends the whole list.
|
|
1614
|
-
else if (out.kind === 'plan') {
|
|
1615
|
-
state.plan = out.items;
|
|
1616
|
-
state.tasks = {
|
|
1617
|
-
done: out.items.filter((t) => t.status === 'completed').length,
|
|
1618
|
-
total: out.items.length,
|
|
1619
|
-
};
|
|
1620
|
-
void report();
|
|
1621
|
-
}
|
|
1622
|
-
else if (out.kind === 'ready') {
|
|
1623
|
-
if (out.sessionId)
|
|
1624
|
-
sessions.set(key, out.sessionId);
|
|
1625
|
-
// The preflight proved the server answers; this proves the SESSION got it. The SDK will
|
|
1626
|
-
// start the model with a server still 'pending' or 'needs-auth' and list its tools from
|
|
1627
|
-
// memory, so a count alone is not enough — the status has to say connected. Refuse the
|
|
1628
|
-
// turn otherwise: the event stays queued and comes back once the tools are really there.
|
|
1629
|
-
const fd = out.mcp.find((s) => s.name === config.mcpServerName);
|
|
1630
|
-
log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
|
|
1631
|
-
if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
|
|
1632
|
-
throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
|
|
2518
|
+
if (out.kind === 'tool') {
|
|
2519
|
+
const stepBack = struggling.saw(out.name);
|
|
2520
|
+
if (stepBack) {
|
|
2521
|
+
log('warn', `${struggling.refusals()} refusals this turn — telling it to stop and ask`);
|
|
2522
|
+
const heard = await run.steer(stepBack).catch(() => false);
|
|
2523
|
+
// An engine that cannot be steered gets stopped instead: letting it carry on is how a
|
|
2524
|
+
// turn becomes twenty minutes of working around a guard.
|
|
2525
|
+
if (!heard) {
|
|
2526
|
+
log('warn', 'cannot steer this engine — ending the turn');
|
|
2527
|
+
await run.stop().catch(() => { });
|
|
2528
|
+
}
|
|
2529
|
+
if (tag)
|
|
2530
|
+
void tellLeadItIsStuck(tag, struggling.refusals());
|
|
2531
|
+
}
|
|
1633
2532
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
if (
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
2533
|
+
state.tasks = status.snapshot().tasks;
|
|
2534
|
+
// An answer to a question typed here is the whole point of the turn, so it is shown whole;
|
|
2535
|
+
// a card turn's narration is one line each, the card holds the full account.
|
|
2536
|
+
if (out.kind === 'text')
|
|
2537
|
+
log(event.event === 'ask' ? 'answer' : 'say', out.text);
|
|
2538
|
+
else if (out.kind === 'tool')
|
|
2539
|
+
log('tool', out.name);
|
|
2540
|
+
// A changed plan is the most informative thing an agent emits, so it is pushed at once
|
|
2541
|
+
// rather than waiting for the timer.
|
|
2542
|
+
else if (out.kind === 'todo') {
|
|
2543
|
+
log('todo', out.text);
|
|
2544
|
+
void report();
|
|
1644
2545
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
2546
|
+
// The plan replaces the previous one — TodoWrite always sends the whole list.
|
|
2547
|
+
else if (out.kind === 'plan') {
|
|
2548
|
+
state.plan = out.items;
|
|
2549
|
+
state.tasks = {
|
|
2550
|
+
done: out.items.filter((t) => t.status === 'completed').length,
|
|
2551
|
+
total: out.items.length,
|
|
2552
|
+
};
|
|
2553
|
+
void report();
|
|
2554
|
+
}
|
|
2555
|
+
else if (out.kind === 'ready') {
|
|
2556
|
+
if (out.sessionId)
|
|
2557
|
+
sessions.set(key, out.sessionId);
|
|
2558
|
+
// The preflight proved the server answers; this proves the SESSION got it. The SDK will
|
|
2559
|
+
// start the model with a server still 'pending' or 'needs-auth' and list its tools from
|
|
2560
|
+
// memory, so a count alone is not enough — the status has to say connected. Refuse the
|
|
2561
|
+
// turn otherwise: the event stays queued and comes back once the tools are really there.
|
|
2562
|
+
const fd = out.mcp.find((s) => s.name === config.mcpServerName);
|
|
2563
|
+
log('tool', `session: FlareDeck ${fd?.status ?? 'absent'} · ${out.tools.length} tools · ${out.permissionMode}`);
|
|
2564
|
+
if (!fd || fd.status !== 'connected' || out.tools.length === 0) {
|
|
2565
|
+
throw new CouldNotWork(`this session's FlareDeck server is ${fd?.status ?? 'absent'}, so the card cannot be read — leaving the event queued`);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
else if (out.kind === 'usage') {
|
|
1652
2569
|
turnTokens = out.contextTokens;
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
2570
|
+
if (!nudged && turnTokens >= CONTEXT_WRAP_UP_TOKENS && liveRun) {
|
|
2571
|
+
nudged = true;
|
|
2572
|
+
log('warn', `context at ${Math.round(turnTokens / 1000)}k — asking it to wrap up before the window fills`);
|
|
2573
|
+
const wrapUpText = `Your context is at ${Math.round(turnTokens / 1000)}k tokens and will be compacted soon, which loses detail. ` +
|
|
2574
|
+
'Finish the step you are on, then stop: comment on the card with where things stand, put anything half-done ' +
|
|
2575
|
+
'and anything that did NOT work into set-card-notes, and end the turn. A fresh session will pick the card up from the note.';
|
|
2576
|
+
void liveRun.steer(wrapUpText);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
else if (out.kind === 'compacted')
|
|
2580
|
+
log('warn', 'the SDK compacted this session mid-turn — detail before this point is summarised, not kept');
|
|
2581
|
+
else if (out.kind === 'done') {
|
|
2582
|
+
if (!out.ok)
|
|
2583
|
+
log('warn', 'the turn did not finish cleanly');
|
|
2584
|
+
if (out.contextTokens)
|
|
2585
|
+
turnTokens = out.contextTokens;
|
|
2586
|
+
// The number the intake step is meant to move: how big this turn's context got.
|
|
2587
|
+
if (turnTokens)
|
|
2588
|
+
log('tool', `turn: ${Math.round(turnTokens / 1000)}k context${prepared?.size ? ` · ${prepared.size} work` : ''}`);
|
|
2589
|
+
}
|
|
1656
2590
|
}
|
|
1657
2591
|
}
|
|
2592
|
+
catch (e) {
|
|
2593
|
+
runError = e;
|
|
2594
|
+
}
|
|
1658
2595
|
liveRun = null;
|
|
2596
|
+
/**
|
|
2597
|
+
* The plan failed, not the work: switch plans and run this event again, now.
|
|
2598
|
+
*
|
|
2599
|
+
* Two causes, told apart because they are fixed differently. A spent window is already filed
|
|
2600
|
+
* above (awaited here, so `choose` sees it). A rejected token — "401 Invalid bearer token",
|
|
2601
|
+
* a mis-pasted or revoked setup-token — is not capacity and is not filed as spent; it is
|
|
2602
|
+
* excluded for this event, and says plainly that the token needs replacing.
|
|
2603
|
+
*
|
|
2604
|
+
* The retry resumes the same session, so work already done in the turn is not lost; it costs
|
|
2605
|
+
* the prompt cache, which is the price of carrying on at all.
|
|
2606
|
+
*/
|
|
2607
|
+
if (claudePlan?.use === 'token') {
|
|
2608
|
+
const said = `${runError instanceof Error ? runError.message : runError ?? ''} ${doneText}`;
|
|
2609
|
+
const rejected = /API Error: 401|invalid bearer token|OAuth token has expired|OAuth token (was )?revoked/i.test(said);
|
|
2610
|
+
if (rejected || spentAny) {
|
|
2611
|
+
await Promise.allSettled(spentReports);
|
|
2612
|
+
const tried = [...(failedPlans.get(event.id) ?? []), claudePlan.id];
|
|
2613
|
+
if (rejected) {
|
|
2614
|
+
log('warn', `claude: ${claudePlan.label} — its token was rejected. Replace it on Integrations → Secrets.`);
|
|
2615
|
+
}
|
|
2616
|
+
if (tried.length <= MAX_PLAN_SWITCHES) {
|
|
2617
|
+
failedPlans.set(event.id, tried);
|
|
2618
|
+
throw new RetryOnAnotherPlan(`${claudePlan.label} ${rejected ? 'rejected its token' : 'is at its limit'} — switching subscription and trying again`);
|
|
2619
|
+
}
|
|
2620
|
+
failedPlans.delete(event.id);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
failedPlans.delete(event.id);
|
|
2624
|
+
if (runError)
|
|
2625
|
+
throw runError;
|
|
1659
2626
|
// A question from the pane: no card to hand over to. Its session is kept so the next question
|
|
1660
2627
|
// can build on this one, and simply dropped once it has grown too large to be worth carrying.
|
|
1661
2628
|
if (event.event === 'ask') {
|
|
@@ -1817,8 +2784,8 @@ export async function serve(opts) {
|
|
|
1817
2784
|
ui = startUi({ state: () => ({ ...state, queued: queue.length }), onSteer: steer, onQuit: shutdown, ...(decide ? { onDecide: decide } : {}) });
|
|
1818
2785
|
try {
|
|
1819
2786
|
for await (const event of stream.events((s) => {
|
|
1820
|
-
state.connected = s
|
|
1821
|
-
log(
|
|
2787
|
+
state.connected = s.startsWith('listening');
|
|
2788
|
+
log(state.connected ? 'tool' : 'warn', s);
|
|
1822
2789
|
})) {
|
|
1823
2790
|
// A verdict on a request this agent is waiting on. Handled HERE, not in drain(): the turn
|
|
1824
2791
|
// that asked is still running — blocked inside the tool call — and drain is serialised
|
|
@@ -1832,11 +2799,34 @@ export async function serve(opts) {
|
|
|
1832
2799
|
beat();
|
|
1833
2800
|
continue;
|
|
1834
2801
|
}
|
|
2802
|
+
// Paused: nothing is worked. Acknowledged, because its cards went back to the lead and the
|
|
2803
|
+
// lead decides what happens to them; carrying a backlog through a pause would replay it.
|
|
2804
|
+
if (paused) {
|
|
2805
|
+
log('tool', `paused — left for the lead: ${event.line}`);
|
|
2806
|
+
await stream.ack([event.id]);
|
|
2807
|
+
beat();
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
// Another board's work. Acknowledged so it does not come round again, and never read: no
|
|
2811
|
+
// intake, no Jev call, no turn. One line, so it is visible that scope is doing something
|
|
2812
|
+
// rather than that the agent has gone deaf.
|
|
2813
|
+
if (!onMyBoards(myBoards, event)) {
|
|
2814
|
+
log('tool', `not this agent's board (${event.boardName || event.boardKey}) — ${event.line}`);
|
|
2815
|
+
await stream.ack([event.id]);
|
|
2816
|
+
beat();
|
|
2817
|
+
continue;
|
|
2818
|
+
}
|
|
1835
2819
|
// A review request for this lead: the model handles it as a turn, but the list can show
|
|
1836
2820
|
// the row straight away.
|
|
1837
2821
|
if (event.event === 'review')
|
|
1838
2822
|
void refreshApprovals();
|
|
1839
|
-
|
|
2823
|
+
// A review jumps the queue. It has a deadline and a worker blocked on it; behind a backlog
|
|
2824
|
+
// it waited for every card ahead of it. HAL's request sat behind seven events on a freshly
|
|
2825
|
+
// restarted JARVIS, listed in the pane as "with lead" and never looked at, while the hour
|
|
2826
|
+
// ran down. Front, not interrupt: the turn already running finishes first.
|
|
2827
|
+
if (enqueue(event, event.event === 'review')) {
|
|
2828
|
+
if (event.event === 'review')
|
|
2829
|
+
log('event', `review waiting — taken next: ${event.line}`);
|
|
1840
2830
|
prepareAhead(event);
|
|
1841
2831
|
void drain();
|
|
1842
2832
|
}
|
|
@@ -1879,6 +2869,18 @@ async function baseBranch(cwd) {
|
|
|
1879
2869
|
c.on('error', () => resolve('main'));
|
|
1880
2870
|
});
|
|
1881
2871
|
}
|
|
2872
|
+
/**
|
|
2873
|
+
* What a lead's sweep must achieve, as a `/goal` condition (under 4000 characters). Judged by a
|
|
2874
|
+
* small model from the transcript alone, so the proof has to be IN the transcript: a final
|
|
2875
|
+
* get-portfolio, quoted. Bounded, because a column that cannot be emptied (no free worker, every
|
|
2876
|
+
* card blocked) must end in reasons, not in a loop.
|
|
2877
|
+
*/
|
|
2878
|
+
const LEAD_SWEEP_GOAL = 'Every card in your board\'s ready-for-work column (the lane FlareDeck calls Ready For Dev — its ' +
|
|
2879
|
+
'type is todo) has been dealt with: handed to a worker that reports to you and is free, with ' +
|
|
2880
|
+
'assign-card, or left there with a one-line reason it must wait (no free worker, blocked, ' +
|
|
2881
|
+
'waiting on a person). You dispatch; you do not build cards yourself. The goal is met only when ' +
|
|
2882
|
+
'your most recent turn called get-portfolio and you quoted, from that output, how many cards ' +
|
|
2883
|
+
'are in the ready column and, for each card still there, its one-line reason. Or stop after 8 turns.';
|
|
1882
2884
|
function prompt(event, isLead = false) {
|
|
1883
2885
|
if (event.event === 'sweep') {
|
|
1884
2886
|
return [
|
|
@@ -1900,6 +2902,16 @@ function prompt(event, isLead = false) {
|
|
|
1900
2902
|
'assign-card, newest bugs first. One each — an agent already on a card is refused a',
|
|
1901
2903
|
'second, and it messages you the moment it is free.',
|
|
1902
2904
|
'',
|
|
2905
|
+
'Then look at the balance. list_agents shows what each one holds and how many are',
|
|
2906
|
+
'queued behind it. Where one worker has several cards waiting and another has none,',
|
|
2907
|
+
'move a card across with assign-card: a card queued behind a busy agent is not being',
|
|
2908
|
+
'worked, and the idle one could finish it today.',
|
|
2909
|
+
'',
|
|
2910
|
+
'Each agent is shown with the repository it has checked out. Move a card only to an',
|
|
2911
|
+
'agent that has the repository the card is for — on a board where everyone works the',
|
|
2912
|
+
'same one they are interchangeable, and on a board with a ui agent and an api agent',
|
|
2913
|
+
'they are not.',
|
|
2914
|
+
'',
|
|
1903
2915
|
]
|
|
1904
2916
|
: []),
|
|
1905
2917
|
'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
|
|
@@ -1910,11 +2922,13 @@ function prompt(event, isLead = false) {
|
|
|
1910
2922
|
'',
|
|
1911
2923
|
'- A card for one of the agents who report to you: assign it to the right one. That dispatch',
|
|
1912
2924
|
' IS the work, and this turn is done.',
|
|
1913
|
-
'- A card that is yours and NOT already in progress:
|
|
1914
|
-
' with
|
|
1915
|
-
'
|
|
1916
|
-
'
|
|
1917
|
-
'
|
|
2925
|
+
'- A card that is yours and NOT already in progress: hand it to a worker if you have one free,',
|
|
2926
|
+
' with assign-card. You run one turn at a time, and a card takes minutes — for all of them you',
|
|
2927
|
+
' would be unable to assign anything or answer anyone, which is the one job only you can do.',
|
|
2928
|
+
' With nobody free, move it to the board\'s in-progress lane with update-card-status and stop:',
|
|
2929
|
+
' that hands it back to you as a card of its own, with a checkout and a branch, which is the',
|
|
2930
|
+
' only place work can be done and published from. Either way, stop here — reading it, starting',
|
|
2931
|
+
' it or commenting on it in this turn all edit the shared checkout, which publishes nowhere.',
|
|
1918
2932
|
'- A card already in progress: say which one in a line, and stop. The harness picks those up',
|
|
1919
2933
|
' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
|
|
1920
2934
|
'',
|