@onepointfour-packs/flaredeck-agent 0.2.36 → 0.2.38
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/boards.d.ts +72 -0
- package/dist/boards.js +136 -0
- package/dist/boards.js.map +1 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +77 -11
- package/dist/browser.js.map +1 -1
- package/dist/config.d.ts +16 -0
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/environment.d.ts +22 -1
- package/dist/environment.js +95 -8
- package/dist/environment.js.map +1 -1
- 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/main.js +56 -15
- package/dist/main.js.map +1 -1
- package/dist/picker.d.ts +10 -0
- package/dist/picker.js +104 -0
- package/dist/picker.js.map +1 -0
- package/dist/repopicker.js +8 -96
- package/dist/repopicker.js.map +1 -1
- package/dist/runs.d.ts +9 -2
- package/dist/runs.js +33 -5
- package/dist/runs.js.map +1 -1
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +286 -40
- package/dist/serve.js.map +1 -1
- package/dist/stream.d.ts +4 -0
- package/dist/stream.js.map +1 -1
- 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 +4 -1
- package/dist/ui.js +38 -20
- package/dist/ui.js.map +1 -1
- package/dist/workspace.d.ts +28 -2
- package/dist/workspace.js +39 -2
- package/dist/workspace.js.map +1 -1
- package/package.json +1 -1
package/dist/serve.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
1
2
|
import { createRequire } from 'node:module';
|
|
2
|
-
import {
|
|
3
|
+
import { ensureTmuxScrollback } from './tmux.js';
|
|
4
|
+
import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
|
|
3
5
|
import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
|
|
4
6
|
import { freshGitHubToken } from './githubauth.js';
|
|
5
7
|
import { loadBaseBranch, loadMergeConfig, preflight, preflightBrief } from './preflight.js';
|
|
@@ -24,6 +26,7 @@ import { managedPython, usingManagedPython } from './engine-antigravity.js';
|
|
|
24
26
|
import { mkdirSync } from 'node:fs';
|
|
25
27
|
import { join } from 'node:path';
|
|
26
28
|
import { configDir, saveConfig } from './config.js';
|
|
29
|
+
import { boardArg, onMyBoards, outstandingCards } from './boards.js';
|
|
27
30
|
import { browserTools } from './browser.js';
|
|
28
31
|
import { watchForDesperation } from './desperation.js';
|
|
29
32
|
import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
|
|
@@ -173,7 +176,11 @@ export async function serve(opts) {
|
|
|
173
176
|
if (!['message', 'commented', 'ask'].includes(event.event))
|
|
174
177
|
return null;
|
|
175
178
|
const text = `${event.line ?? ''} ${event.message ?? ''}`;
|
|
176
|
-
|
|
179
|
+
// Prose a person typed, so there is no field to read and a pattern is the only way. The
|
|
180
|
+
// bound was ten characters before the dash, which silently excluded real boards —
|
|
181
|
+
// DOCUMENTREPOSITOR-0022 is seventeen. Where a TOOL answers, the code is read from the
|
|
182
|
+
// fields it prints instead; see outstandingCards.
|
|
183
|
+
const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,23}-\d{3,}\b/g) ?? [])];
|
|
177
184
|
for (const code of codes) {
|
|
178
185
|
// What the harness wrote down when it made the worktree, first: the directory is named from
|
|
179
186
|
// the card's internal id and the branch from whatever the agent chose, so neither can be
|
|
@@ -228,6 +235,12 @@ export async function serve(opts) {
|
|
|
228
235
|
if (await releaseSharedPushGuard(c.path).catch(() => false))
|
|
229
236
|
log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
|
|
230
237
|
}
|
|
238
|
+
// Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
|
|
239
|
+
void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
|
|
240
|
+
// A restart begins with no environments: the last run's are nobody's now (see the function).
|
|
241
|
+
for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
|
|
242
|
+
void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
|
|
243
|
+
}
|
|
231
244
|
if (inScope.length > 1) {
|
|
232
245
|
log('tool', `works in ${inScope.length} repositories: ${inScope.map((c) => c.name).join(', ')}`);
|
|
233
246
|
}
|
|
@@ -332,13 +345,22 @@ export async function serve(opts) {
|
|
|
332
345
|
const reader = opts.me ?? opts.deck ?? null;
|
|
333
346
|
const answeredHere = new Map();
|
|
334
347
|
let refreshing = null;
|
|
348
|
+
/** This agent's own id, and (on a lead) the agents that report to it — whose requests it sees. */
|
|
349
|
+
let selfId = '';
|
|
350
|
+
let teamIds = new Set();
|
|
335
351
|
const refreshApprovals = () => {
|
|
336
352
|
if (!reader)
|
|
337
353
|
return Promise.resolve();
|
|
338
354
|
if (refreshing)
|
|
339
355
|
return refreshing;
|
|
340
356
|
refreshing = reader.pendingApprovals().then((all) => {
|
|
341
|
-
|
|
357
|
+
// Its own requests, and on a lead its team's. Every pending request in the workspace used
|
|
358
|
+
// to be listed on every pane, so CASE showed HAL's request as if it were CASE's business.
|
|
359
|
+
// Until this agent knows who it is (the team read failed), nothing is hidden.
|
|
360
|
+
const ours = (a) => !selfId
|
|
361
|
+
|| a.sender_key === selfId
|
|
362
|
+
|| (isLead && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
|
|
363
|
+
const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST && ours(a));
|
|
342
364
|
state.approvals = mine.map((a) => {
|
|
343
365
|
const lines = (a.input_preview || '').split('\n');
|
|
344
366
|
const preview = lines.find((l) => l.startsWith('-- RAN') || /would_touch/.test(l))?.replace(/^-- /, '').replace(/^harness preview.*?:\s*/, '');
|
|
@@ -453,29 +475,58 @@ export async function serve(opts) {
|
|
|
453
475
|
return null;
|
|
454
476
|
return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
|
|
455
477
|
},
|
|
456
|
-
unflag: async () => {
|
|
457
|
-
const cur = onCard;
|
|
458
|
-
if (!cur)
|
|
459
|
-
return;
|
|
478
|
+
unflag: async (cur) => {
|
|
460
479
|
parked.delete(cur.card);
|
|
461
480
|
await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
|
|
462
481
|
card_id: cur.code || cur.card,
|
|
463
482
|
...(cur.board ? { board_id: cur.board } : {}),
|
|
464
483
|
}).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
|
|
465
484
|
},
|
|
466
|
-
}, () => leadId, onAsk)
|
|
485
|
+
}, () => leadId, onAsk, (card, text) => resumeParked(card, text))
|
|
467
486
|
: undefined;
|
|
468
487
|
// Answering from this keyboard: the person's own login decides, through the same endpoint the
|
|
469
488
|
// phone uses, so the agent waiting on it sees the verdict as any other.
|
|
470
|
-
|
|
489
|
+
/**
|
|
490
|
+
* The person answering from this pane. Signed in once per machine, from the pane when nobody
|
|
491
|
+
* has: press a/d and it asks. Never the agent's own login — an approval is a person's decision,
|
|
492
|
+
* and the agent has a shell on this box and its own credentials in it.
|
|
493
|
+
*/
|
|
494
|
+
let me = opts.me;
|
|
495
|
+
state.person = !!me;
|
|
496
|
+
const startSignIn = () => {
|
|
497
|
+
if (signingIn) {
|
|
498
|
+
log('event', 'still waiting for the sign-in — paste the address your browser ended on');
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!opts.signInMe) {
|
|
502
|
+
log('warn', 'nobody is signed in here to answer with — run: flaredeck-agent login --me');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
signingIn = true;
|
|
506
|
+
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.');
|
|
507
|
+
opts.signInMe((t) => { if (t.trim())
|
|
508
|
+
log('event', t); }, signInReader)
|
|
509
|
+
.then((client) => {
|
|
510
|
+
me = client ?? undefined;
|
|
511
|
+
state.person = !!me;
|
|
512
|
+
log(me ? 'event' : 'warn', me ? 'signed in — a approves, d denies, from this pane' : 'signed in, but that login has no workspace here');
|
|
513
|
+
})
|
|
514
|
+
.catch((e) => log('warn', `sign-in did not finish: ${e instanceof Error ? e.message : String(e)}`))
|
|
515
|
+
.finally(() => { signingIn = false; });
|
|
516
|
+
};
|
|
517
|
+
const decide = opts.signInMe || opts.me
|
|
471
518
|
? (requestId, verdict) => {
|
|
519
|
+
if (!me) {
|
|
520
|
+
startSignIn();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
472
523
|
const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
|
|
473
524
|
if (!row || row.stage !== 'ready')
|
|
474
525
|
return;
|
|
475
526
|
answeredHere.set(requestId, verdict);
|
|
476
527
|
row.stage = 'answered';
|
|
477
528
|
row.verdict = verdict;
|
|
478
|
-
void
|
|
529
|
+
void me.decide(row.commentId, verdict)
|
|
479
530
|
.then(() => log('you', `${verdict === 'allow' ? 'approved' : 'denied'} ${row.name} (${row.agent}, ${row.card}) from here`))
|
|
480
531
|
.catch((e) => { answeredHere.delete(requestId); log('warn', `could not record that: ${e instanceof Error ? e.message : String(e)}`); void refreshApprovals(); });
|
|
481
532
|
}
|
|
@@ -559,6 +610,8 @@ export async function serve(opts) {
|
|
|
559
610
|
if (leadId)
|
|
560
611
|
log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
|
|
561
612
|
isLead = team.you.role === 'lead';
|
|
613
|
+
selfId = team.you.agentId;
|
|
614
|
+
teamIds = new Set(team.agents.filter((a) => a.reportsTo === team.you.agentId).map((a) => a.agentId));
|
|
562
615
|
if (team.you.role === 'lead') {
|
|
563
616
|
teamSet = {
|
|
564
617
|
// After a review the list changes — an endorsed request is now answerable, from THIS
|
|
@@ -583,11 +636,34 @@ export async function serve(opts) {
|
|
|
583
636
|
* Polled rather than pushed, because nothing streams another agent's status — and slowly,
|
|
584
637
|
* since agents check in every five minutes, so a faster poll would show the same row again.
|
|
585
638
|
*/
|
|
639
|
+
/**
|
|
640
|
+
* An idle worker is the lead's problem, and the lead already knows within a minute.
|
|
641
|
+
*
|
|
642
|
+
* It polls its team every sixty seconds to draw the pane, so "CASE idle 4m ago" is sitting on
|
|
643
|
+
* its screen — and it did nothing with it. Dispatch waited for the hourly sweep, or for the
|
|
644
|
+
* worker to say it was free, which for a while it never did. Eleven cards waited in Ready For
|
|
645
|
+
* Dev while two agents sat idle and the lead reported "waiting for work".
|
|
646
|
+
*
|
|
647
|
+
* The poll is already happening, so noticing costs nothing. Rate-limited because an idle
|
|
648
|
+
* worker with genuinely nothing to give it stays idle, and a lead asked about it every minute
|
|
649
|
+
* learns to say "nothing" without looking.
|
|
650
|
+
*/
|
|
651
|
+
const DISPATCH_AGAIN_MS = 10 * 60 * 1000;
|
|
652
|
+
let lastDispatch = 0;
|
|
653
|
+
const workersAreIdle = (team) => myTeam(team).mine.filter((a) => !a.card && a.state !== 'stopped' && !a.stale).map((a) => a.name);
|
|
586
654
|
if (leadOfTeam) {
|
|
587
655
|
const teamTimer = setInterval(() => {
|
|
588
656
|
void (async () => {
|
|
589
657
|
try {
|
|
590
|
-
|
|
658
|
+
const team = await fetchTeam(opts.baseUrl, await opts.auth());
|
|
659
|
+
showTeam(team);
|
|
660
|
+
const free = workersAreIdle(team);
|
|
661
|
+
if (free.length && !working && !queue.length && Date.now() - lastDispatch > DISPATCH_AGAIN_MS) {
|
|
662
|
+
lastDispatch = Date.now();
|
|
663
|
+
log('event', `${free.join(', ')} ${free.length === 1 ? 'is' : 'are'} idle — looking for work to hand over`);
|
|
664
|
+
if (enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: `${free.join(', ')} have nothing to do` }))
|
|
665
|
+
void drain();
|
|
666
|
+
}
|
|
591
667
|
}
|
|
592
668
|
catch {
|
|
593
669
|
// A team the lead briefly cannot see is not worth a line in the log every minute; the
|
|
@@ -619,12 +695,40 @@ export async function serve(opts) {
|
|
|
619
695
|
if (engine.name !== 'claude')
|
|
620
696
|
log('event', `${engine.name} is answering on this agent`);
|
|
621
697
|
let liveRun = null;
|
|
698
|
+
/**
|
|
699
|
+
* This turn's browser, so it can be closed when the turn ends.
|
|
700
|
+
*
|
|
701
|
+
* A fresh tool set is built per turn, so without this the previous turn's Chromium was both
|
|
702
|
+
* unreachable and still running — a leaked browser tree for every turn that looked at
|
|
703
|
+
* anything, on a process that stays up for days.
|
|
704
|
+
*/
|
|
705
|
+
let turnEyes = null;
|
|
622
706
|
/**
|
|
623
707
|
* Typed with nothing running: a question for the agent, answered now. It goes through the same
|
|
624
708
|
* queue as a card event so it is serialised with real work, but it is local — never acknowledged
|
|
625
709
|
* to the server, never a card — and it opens one long-lived session of its own so a follow-up
|
|
626
710
|
* question remembers the last one.
|
|
627
711
|
*/
|
|
712
|
+
/**
|
|
713
|
+
* A parked card's request was answered: put the card back at the FRONT of the queue, resuming
|
|
714
|
+
* its own session in its own worktree, with what happened. The turn on whatever card is running
|
|
715
|
+
* now finishes first.
|
|
716
|
+
*/
|
|
717
|
+
const resumeParked = (card, text) => {
|
|
718
|
+
const event = {
|
|
719
|
+
id: `run-${card.card}-${Date.now()}`,
|
|
720
|
+
event: 'run-answered',
|
|
721
|
+
line: text,
|
|
722
|
+
message: text,
|
|
723
|
+
cardKey: card.card,
|
|
724
|
+
...(card.board ? { boardKey: card.board } : {}),
|
|
725
|
+
...(card.code ? { cardCode: card.code } : {}),
|
|
726
|
+
};
|
|
727
|
+
log('event', `${card.code || card.card}: request answered — back at the front of the queue`);
|
|
728
|
+
if (enqueue(event, true)) {
|
|
729
|
+
void drain();
|
|
730
|
+
}
|
|
731
|
+
};
|
|
628
732
|
const ask = (text) => {
|
|
629
733
|
const event = { id: `ask-${Date.now()}`, event: 'ask', line: text, message: text };
|
|
630
734
|
if (enqueue(event)) {
|
|
@@ -632,7 +736,74 @@ export async function serve(opts) {
|
|
|
632
736
|
void drain();
|
|
633
737
|
}
|
|
634
738
|
};
|
|
739
|
+
/**
|
|
740
|
+
* `!command` runs in a shell, the way Claude Code's prompt does — for the person at this
|
|
741
|
+
* terminal, not the agent. It runs where the agent is working (the card's worktree, else the
|
|
742
|
+
* repo), so `!npm install` fixes the checkout the agent is stuck in. Async on purpose: a
|
|
743
|
+
* blocking call here would freeze the pane and the agent's own event stream for as long as the
|
|
744
|
+
* command took. The agent never sees it; say on the card or in a steer if it should know.
|
|
745
|
+
*/
|
|
746
|
+
/** A person signing in from the pane: lines typed meanwhile go to the sign-in, not the agent. */
|
|
747
|
+
let signingIn = false;
|
|
748
|
+
const signInReader = Object.assign(new EventEmitter(), { prompt() { }, setPrompt() { }, close() { } });
|
|
749
|
+
/** The checkout the current turn is working in ('' for one that has none). */
|
|
750
|
+
let turnCwd = '';
|
|
751
|
+
/** Worktrees whose environment comes down unless a turn returns to them first. */
|
|
752
|
+
const idleStops = new Map();
|
|
753
|
+
const ENV_IDLE_MS = 20 * 60_000;
|
|
754
|
+
const scheduleEnvironmentStop = (cwd) => {
|
|
755
|
+
const before = idleStops.get(cwd);
|
|
756
|
+
if (before)
|
|
757
|
+
clearTimeout(before);
|
|
758
|
+
idleStops.set(cwd, setTimeout(() => {
|
|
759
|
+
idleStops.delete(cwd);
|
|
760
|
+
// Never the checkout a turn is in right now, even if the timer and the turn cross.
|
|
761
|
+
if (cwd === turnCwd && state.working)
|
|
762
|
+
return;
|
|
763
|
+
void stopEnvironment(cwd).then((ok) => { if (ok)
|
|
764
|
+
log('tool', `environment stopped in ${cwd.split('/').pop()} (idle 20m)`); }).catch(() => { });
|
|
765
|
+
}, ENV_IDLE_MS));
|
|
766
|
+
idleStops.get(cwd).unref?.();
|
|
767
|
+
};
|
|
768
|
+
const shell = (command) => {
|
|
769
|
+
const cwd = job?.cwd ?? config.repo;
|
|
770
|
+
log('you', `! ${command}`);
|
|
771
|
+
log('event', `running in ${cwd}`);
|
|
772
|
+
void import('node:child_process').then(({ spawn }) => {
|
|
773
|
+
const child = spawn('bash', ['-lc', command], { cwd, env: process.env });
|
|
774
|
+
const emit = (chunk, partial) => {
|
|
775
|
+
partial.s += chunk.toString('utf8');
|
|
776
|
+
const parts = partial.s.split(/\r?\n/);
|
|
777
|
+
partial.s = parts.pop() ?? '';
|
|
778
|
+
for (const ln of parts)
|
|
779
|
+
log('shell', ln);
|
|
780
|
+
};
|
|
781
|
+
const out = { s: '' };
|
|
782
|
+
const err = { s: '' };
|
|
783
|
+
child.stdout.on('data', (c) => emit(c, out));
|
|
784
|
+
child.stderr.on('data', (c) => emit(c, err));
|
|
785
|
+
child.on('error', (e) => log('warn', `could not run it: ${e.message}`));
|
|
786
|
+
child.on('close', (code) => {
|
|
787
|
+
if (out.s)
|
|
788
|
+
log('shell', out.s);
|
|
789
|
+
if (err.s)
|
|
790
|
+
log('shell', err.s);
|
|
791
|
+
log(code === 0 ? 'event' : 'warn', `exit ${code ?? '?'}`);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
};
|
|
635
795
|
const steer = (text) => {
|
|
796
|
+
// Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
|
|
797
|
+
if (signingIn) {
|
|
798
|
+
signInReader.emit('line', text);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (text.startsWith('!')) {
|
|
802
|
+
const command = text.slice(1).trim();
|
|
803
|
+
if (command)
|
|
804
|
+
shell(command);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
636
807
|
log('you', text);
|
|
637
808
|
if (liveRun) {
|
|
638
809
|
// Stamped human: the SDK treats an unattributed message as untrusted, and rightly — a line
|
|
@@ -697,6 +868,11 @@ export async function serve(opts) {
|
|
|
697
868
|
const pending = new Set();
|
|
698
869
|
/** Keep the pushed status in step with the queue — it is what a lead's pane reads. */
|
|
699
870
|
const syncQueue = () => status.setQueue(queue.length, queue[0]?.cardCode || queue[0]?.cardKey || '');
|
|
871
|
+
/**
|
|
872
|
+
* The boards this agent works, by key. Empty means all of them, which is what every install
|
|
873
|
+
* did before this existed.
|
|
874
|
+
*/
|
|
875
|
+
const myBoards = new Set((config.boards ?? []).map((b) => b.key));
|
|
700
876
|
const enqueue = (event, front = false) => {
|
|
701
877
|
if (pending.has(event.id))
|
|
702
878
|
return false;
|
|
@@ -722,6 +898,8 @@ export async function serve(opts) {
|
|
|
722
898
|
const wantsIntake = (event) => !!event.cardKey
|
|
723
899
|
&& event.event !== 'review'
|
|
724
900
|
&& event.event !== 'ask'
|
|
901
|
+
// A parked card coming back is the same work resuming, not a new card to brief.
|
|
902
|
+
&& event.event !== 'run-answered'
|
|
725
903
|
&& !(event.event === 'commented' && event.cardDone && !event.mentioned)
|
|
726
904
|
// Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
|
|
727
905
|
// a card this agent once touched keeps sending it every move and comment made by whoever
|
|
@@ -846,10 +1024,19 @@ export async function serve(opts) {
|
|
|
846
1024
|
return;
|
|
847
1025
|
let mine = [];
|
|
848
1026
|
try {
|
|
849
|
-
|
|
1027
|
+
// Scoped, like every other account-wide question. This one is asked by the HARNESS rather
|
|
1028
|
+
// than by the model, so the proxy that narrows the model's calls never saw it — and a
|
|
1029
|
+
// board-scoped agent went straight back to picking up another team's cards: KD-1361, then
|
|
1030
|
+
// KD-1362, from a board nobody had ticked. The card then 404s, because it is not on a
|
|
1031
|
+
// board this agent works.
|
|
1032
|
+
const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', { ...(boardArg([...myBoards]) !== undefined ? { board: boardArg([...myBoards]) } : {}) });
|
|
850
1033
|
const text = typeof out === 'string' ? out : JSON.stringify(out);
|
|
851
|
-
//
|
|
852
|
-
|
|
1034
|
+
// Read from the fields the answer prints, and filtered here as well as asked for — the
|
|
1035
|
+
// server is deployed separately from this, so the scope cannot depend on its version.
|
|
1036
|
+
// In the order it names them: priority order, which is the board's.
|
|
1037
|
+
mine = outstandingCards(text)
|
|
1038
|
+
.filter((c) => onMyBoards(myBoards, c))
|
|
1039
|
+
.map((c) => ({ code: c.code }));
|
|
853
1040
|
}
|
|
854
1041
|
catch {
|
|
855
1042
|
return; // unreachable board is the stream's problem to report, not this timer's
|
|
@@ -951,6 +1138,7 @@ export async function serve(opts) {
|
|
|
951
1138
|
status.startCard(current, !!event.cardKey);
|
|
952
1139
|
void report();
|
|
953
1140
|
let didWork = true;
|
|
1141
|
+
turnCwd = '';
|
|
954
1142
|
try {
|
|
955
1143
|
await handle(event);
|
|
956
1144
|
}
|
|
@@ -964,7 +1152,7 @@ export async function serve(opts) {
|
|
|
964
1152
|
status.finishCard(didWork);
|
|
965
1153
|
void report();
|
|
966
1154
|
pending.delete(event.id);
|
|
967
|
-
if (event.event === 'ask' || event.event === 'sweep') { /* local: nothing to acknowledge, nothing to retry */ }
|
|
1155
|
+
if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
|
|
968
1156
|
else if (didWork)
|
|
969
1157
|
await stream.ack([event.id]);
|
|
970
1158
|
else {
|
|
@@ -981,6 +1169,17 @@ export async function serve(opts) {
|
|
|
981
1169
|
// model: uncommitted changes, or commits that never reached the remote.
|
|
982
1170
|
if (tag)
|
|
983
1171
|
await reportUnfinished(tag);
|
|
1172
|
+
// A card's environment is kept warm for a while — the next comment on it usually comes
|
|
1173
|
+
// soon — and then taken down, so a day of cards does not leave a day of servers running.
|
|
1174
|
+
if (turnCwd && turnCwd !== config.repo)
|
|
1175
|
+
scheduleEnvironmentStop(turnCwd);
|
|
1176
|
+
// The environment is kept warm; the browser is not. It holds a Chromium tree, the next
|
|
1177
|
+
// turn builds its own, and a page left open is not something a later turn can reach.
|
|
1178
|
+
if (turnEyes) {
|
|
1179
|
+
const e = turnEyes;
|
|
1180
|
+
turnEyes = null;
|
|
1181
|
+
await e.close().catch(() => { });
|
|
1182
|
+
}
|
|
984
1183
|
current = '';
|
|
985
1184
|
delete state.plan;
|
|
986
1185
|
state.working = '';
|
|
@@ -994,9 +1193,15 @@ export async function serve(opts) {
|
|
|
994
1193
|
finally {
|
|
995
1194
|
working = false;
|
|
996
1195
|
}
|
|
997
|
-
//
|
|
998
|
-
// once, rather than leaving them to notice.
|
|
999
|
-
|
|
1196
|
+
// An empty queue at the end of a drain means nothing is running and nothing is waiting: this
|
|
1197
|
+
// agent has nothing to do. Tell its lead, once, rather than leaving them to notice.
|
|
1198
|
+
//
|
|
1199
|
+
// This used to require `!live.size` as well, which reads like "nothing running" and is not.
|
|
1200
|
+
// `live` holds sessions kept WARM for fifteen minutes so a follow-up on the same card costs
|
|
1201
|
+
// a resume instead of a re-orientation — a cache, not work. So every agent went quiet for
|
|
1202
|
+
// fifteen minutes after finishing anything, which is exactly the window in which it most
|
|
1203
|
+
// needs a new card. Two sat idle and silent while eleven cards waited.
|
|
1204
|
+
if (!queue.length)
|
|
1000
1205
|
void offerToLead();
|
|
1001
1206
|
}
|
|
1002
1207
|
/**
|
|
@@ -1166,7 +1371,7 @@ export async function serve(opts) {
|
|
|
1166
1371
|
// it may work in — one repo, or every repo in that folder — and the card says which of them
|
|
1167
1372
|
// this work belongs to. See workspace.ts: the folder is the only declaration, so there is no
|
|
1168
1373
|
// config list to go stale.
|
|
1169
|
-
const where = await resolveCheckout(config.repo, event, config.repos);
|
|
1374
|
+
const where = await resolveCheckout(config.repo, event, config.repos, opts.agent ?? '');
|
|
1170
1375
|
let chosen = where.kind === 'repo' ? where.checkout : null;
|
|
1171
1376
|
let fromCard = where.kind === 'repo' && where.branch ? { branch: where.branch, role: where.role } : null;
|
|
1172
1377
|
if (where.kind === 'ambiguous') {
|
|
@@ -1243,6 +1448,13 @@ export async function serve(opts) {
|
|
|
1243
1448
|
? await ensureWorktree(repo, key, (t) => log('tool', t), startFrom, config.worktreeLocalFiles)
|
|
1244
1449
|
: await worktreeTheMessageIsAbout(event, repo, (t) => log('tool', t));
|
|
1245
1450
|
const cwd = tree?.path ?? repo;
|
|
1451
|
+
// Back on this checkout: whatever was waiting to take its environment down can stand down.
|
|
1452
|
+
turnCwd = cwd;
|
|
1453
|
+
const pendingStop = idleStops.get(cwd);
|
|
1454
|
+
if (pendingStop) {
|
|
1455
|
+
clearTimeout(pendingStop);
|
|
1456
|
+
idleStops.delete(cwd);
|
|
1457
|
+
}
|
|
1246
1458
|
// So a later message naming this card by its CODE can find the work. See rememberWorktree.
|
|
1247
1459
|
if (tree && (event.cardCode || event.cardKey)) {
|
|
1248
1460
|
rememberWorktree(event.cardCode || event.cardKey, tree.path, tree.branch);
|
|
@@ -1341,7 +1553,7 @@ export async function serve(opts) {
|
|
|
1341
1553
|
tag = code;
|
|
1342
1554
|
state.working = code;
|
|
1343
1555
|
};
|
|
1344
|
-
const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
|
|
1556
|
+
const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar, [...myBoards]);
|
|
1345
1557
|
const held = live.get(key);
|
|
1346
1558
|
if (held?.timer)
|
|
1347
1559
|
clearTimeout(held.timer);
|
|
@@ -1425,6 +1637,20 @@ export async function serve(opts) {
|
|
|
1425
1637
|
// The guard is built once at startup; what this agent can reach changes per card. Handing it
|
|
1426
1638
|
// the live picture is the difference between judging "$DATABASE_URL" as a scratch database
|
|
1427
1639
|
// and judging it as a live one.
|
|
1640
|
+
// Deployed addresses the board declared for this card's repository — the dev or UAT site. A
|
|
1641
|
+
// card is usually about code that is already live somewhere, and an agent that can only see
|
|
1642
|
+
// what it started locally cannot tell its own change from how the thing already behaved.
|
|
1643
|
+
const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
|
|
1644
|
+
const eyes = forACard
|
|
1645
|
+
? turnEyes = browserTools({
|
|
1646
|
+
repo: config.repo,
|
|
1647
|
+
endpoints: () => env.endpoints,
|
|
1648
|
+
sites: () => declaredSites,
|
|
1649
|
+
contract: () => env.contract,
|
|
1650
|
+
cwd,
|
|
1651
|
+
onNote: (t) => log('tool', t),
|
|
1652
|
+
})
|
|
1653
|
+
: null;
|
|
1428
1654
|
reachable = { cwd, endpoints: env.endpoints };
|
|
1429
1655
|
if (forACard && !needsRunning)
|
|
1430
1656
|
log('tool', 'environment not started — this card does not look like it needs one');
|
|
@@ -1460,19 +1686,12 @@ export async function serve(opts) {
|
|
|
1460
1686
|
...(prepared?.size ? { size: prepared.size } : {}),
|
|
1461
1687
|
tools: {
|
|
1462
1688
|
...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
|
|
1463
|
-
// Eyes,
|
|
1464
|
-
//
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
endpoints: () => env.endpoints,
|
|
1470
|
-
contract: () => env.contract,
|
|
1471
|
-
cwd,
|
|
1472
|
-
onNote: (t) => log('tool', t),
|
|
1473
|
-
}),
|
|
1474
|
-
}
|
|
1475
|
-
: {}),
|
|
1689
|
+
// Eyes, for any card. They used to be mounted only when the harness had itself started
|
|
1690
|
+
// something, which left an agent working alone unable to get them: no start script meant
|
|
1691
|
+
// no browser AND no mention of one, so it could not know that writing the script it is
|
|
1692
|
+
// perfectly able to write was the thing standing between it and seeing its own change.
|
|
1693
|
+
// The endpoints are read through a getter, so something the agent starts mid-turn counts.
|
|
1694
|
+
...(eyes ? { browser: eyes } : {}),
|
|
1476
1695
|
team: teamSet,
|
|
1477
1696
|
flaredeck,
|
|
1478
1697
|
runs: runsSet,
|
|
@@ -1493,7 +1712,7 @@ export async function serve(opts) {
|
|
|
1493
1712
|
freeWorkerBrief(event) +
|
|
1494
1713
|
// Only tell an agent about the environment when the card actually needs one running.
|
|
1495
1714
|
environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
|
|
1496
|
-
browserBrief(env.
|
|
1715
|
+
browserBrief(env.endpoints, declaredSites, forACard) +
|
|
1497
1716
|
(wrongEnv.length
|
|
1498
1717
|
? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
|
|
1499
1718
|
`Anything you test against it tells you about another card's code, and it will answer ` +
|
|
@@ -1757,11 +1976,26 @@ export async function serve(opts) {
|
|
|
1757
1976
|
beat();
|
|
1758
1977
|
continue;
|
|
1759
1978
|
}
|
|
1979
|
+
// Another board's work. Acknowledged so it does not come round again, and never read: no
|
|
1980
|
+
// intake, no Jev call, no turn. One line, so it is visible that scope is doing something
|
|
1981
|
+
// rather than that the agent has gone deaf.
|
|
1982
|
+
if (!onMyBoards(myBoards, event)) {
|
|
1983
|
+
log('tool', `not this agent's board (${event.boardName || event.boardKey}) — ${event.line}`);
|
|
1984
|
+
await stream.ack([event.id]);
|
|
1985
|
+
beat();
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1760
1988
|
// A review request for this lead: the model handles it as a turn, but the list can show
|
|
1761
1989
|
// the row straight away.
|
|
1762
1990
|
if (event.event === 'review')
|
|
1763
1991
|
void refreshApprovals();
|
|
1764
|
-
|
|
1992
|
+
// A review jumps the queue. It has a deadline and a worker blocked on it; behind a backlog
|
|
1993
|
+
// it waited for every card ahead of it. HAL's request sat behind seven events on a freshly
|
|
1994
|
+
// restarted JARVIS, listed in the pane as "with lead" and never looked at, while the hour
|
|
1995
|
+
// ran down. Front, not interrupt: the turn already running finishes first.
|
|
1996
|
+
if (enqueue(event, event.event === 'review')) {
|
|
1997
|
+
if (event.event === 'review')
|
|
1998
|
+
log('event', `review waiting — taken next: ${event.line}`);
|
|
1765
1999
|
prepareAhead(event);
|
|
1766
2000
|
void drain();
|
|
1767
2001
|
}
|
|
@@ -1825,6 +2059,16 @@ function prompt(event, isLead = false) {
|
|
|
1825
2059
|
'assign-card, newest bugs first. One each — an agent already on a card is refused a',
|
|
1826
2060
|
'second, and it messages you the moment it is free.',
|
|
1827
2061
|
'',
|
|
2062
|
+
'Then look at the balance. list_agents shows what each one holds and how many are',
|
|
2063
|
+
'queued behind it. Where one worker has several cards waiting and another has none,',
|
|
2064
|
+
'move a card across with assign-card: a card queued behind a busy agent is not being',
|
|
2065
|
+
'worked, and the idle one could finish it today.',
|
|
2066
|
+
'',
|
|
2067
|
+
'Each agent is shown with the repository it has checked out. Move a card only to an',
|
|
2068
|
+
'agent that has the repository the card is for — on a board where everyone works the',
|
|
2069
|
+
'same one they are interchangeable, and on a board with a ui agent and an api agent',
|
|
2070
|
+
'they are not.',
|
|
2071
|
+
'',
|
|
1828
2072
|
]
|
|
1829
2073
|
: []),
|
|
1830
2074
|
'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
|
|
@@ -1835,11 +2079,13 @@ function prompt(event, isLead = false) {
|
|
|
1835
2079
|
'',
|
|
1836
2080
|
'- A card for one of the agents who report to you: assign it to the right one. That dispatch',
|
|
1837
2081
|
' IS the work, and this turn is done.',
|
|
1838
|
-
'- A card that is yours and NOT already in progress:
|
|
1839
|
-
' with
|
|
1840
|
-
'
|
|
1841
|
-
'
|
|
1842
|
-
'
|
|
2082
|
+
'- A card that is yours and NOT already in progress: hand it to a worker if you have one free,',
|
|
2083
|
+
' with assign-card. You run one turn at a time, and a card takes minutes — for all of them you',
|
|
2084
|
+
' would be unable to assign anything or answer anyone, which is the one job only you can do.',
|
|
2085
|
+
' With nobody free, move it to the board\'s in-progress lane with update-card-status and stop:',
|
|
2086
|
+
' that hands it back to you as a card of its own, with a checkout and a branch, which is the',
|
|
2087
|
+
' only place work can be done and published from. Either way, stop here — reading it, starting',
|
|
2088
|
+
' it or commenting on it in this turn all edit the shared checkout, which publishes nowhere.',
|
|
1843
2089
|
'- A card already in progress: say which one in a line, and stop. The harness picks those up',
|
|
1844
2090
|
' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
|
|
1845
2091
|
'',
|