@kendoo.agentdesk/agentdesk 0.32.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +22 -4
- package/cli/agents.mjs +8 -8
- package/cli/daemon.mjs +63 -9
- package/cli/engine/agents/index.mjs +19 -10
- package/cli/engine/lessons.mjs +171 -0
- package/cli/engine/phases/EXECUTION.md +2 -0
- package/cli/engine/phases/INTAKE.md +2 -1
- package/cli/engine/phases/PLAN.md +4 -3
- package/cli/engine/phases/REVIEW.md +2 -2
- package/cli/engine/phases/SOLO.md +5 -1
- package/cli/engine/phases/SUMMARY.md +5 -4
- package/cli/engine/prompts.mjs +17 -10
- package/cli/engine/recovery.mjs +109 -0
- package/cli/engine/schemas.mjs +39 -8
- package/cli/engine/session.mjs +174 -29
- package/cli/engine/tracker/github.md +2 -2
- package/cli/engine/tracker/jira.md +1 -1
- package/cli/engine/tracker/linear.md +1 -1
- package/cli/engine/verdict.mjs +2 -2
- package/cli/prompt.mjs +5 -12
- package/cli/session-queue.mjs +3 -1
- package/package.json +4 -2
- package/shared/recovery.mjs +28 -0
- package/shared/session-status.mjs +1 -1
package/cli/engine/session.mjs
CHANGED
|
@@ -41,6 +41,10 @@ import { prepareWorkspace } from "../worktrees.mjs";
|
|
|
41
41
|
import { detectProject } from "../detect.mjs";
|
|
42
42
|
import { preparePrivateGit } from "../worktree-git.mjs";
|
|
43
43
|
import { runCancellable } from "./cancellation.mjs";
|
|
44
|
+
import { checkpointStore, validHandoff, journalExternalActions, repairQueryOptions, trackerAccessDenied, recoveryBrief } from "./recovery.mjs";
|
|
45
|
+
import { classifyFailure } from "../../shared/recovery.mjs";
|
|
46
|
+
import { loadProjectMemory } from "../prompt.mjs";
|
|
47
|
+
import { lessonsPath, readLessons, recordLessons, selectLessons } from "./lessons.mjs";
|
|
44
48
|
|
|
45
49
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
46
50
|
const CLI_VERSION = JSON.parse(readFileSync(join(here, "../../package.json"), "utf-8")).version;
|
|
@@ -144,14 +148,33 @@ async function executeSession({
|
|
|
144
148
|
sourceCwd = cwd, workspaceRecord, workspaceStateDir, resumingWorkspace = false,
|
|
145
149
|
workspaceGitEnv = {},
|
|
146
150
|
registerCleanup,
|
|
151
|
+
resumeSession = false, instructions = [],
|
|
147
152
|
}) {
|
|
148
153
|
const startedAt = Date.now();
|
|
149
154
|
const emit = event => {
|
|
150
|
-
if (event.type === "session:update" && event.taskId
|
|
155
|
+
if (event.type === "session:update" && (event.taskId || event.taskLink)) {
|
|
156
|
+
taskId = event.taskId || taskId;
|
|
157
|
+
taskLink = event.taskLink || taskLink;
|
|
158
|
+
recovery.save({ taskId, taskLink });
|
|
159
|
+
}
|
|
151
160
|
onEvent?.({ ...event, timestamp: timestamp() });
|
|
152
161
|
};
|
|
153
|
-
const outcomeIds = new Set();
|
|
154
162
|
const outcomeRepo = githubRepository(sourceCwd || cwd);
|
|
163
|
+
const recovery = checkpointStore(workspaceStateDir || join(cwd, ".agentdesk"), sessionId, { resume: resumeSession });
|
|
164
|
+
const prior = recovery.data;
|
|
165
|
+
const outcomeIds = new Set((prior.outcomes || []).map(o => o.id));
|
|
166
|
+
if (resumeSession) {
|
|
167
|
+
taskId = prior.taskId || taskId;
|
|
168
|
+
description = prior.description ?? description;
|
|
169
|
+
taskLink = prior.taskLink || taskLink;
|
|
170
|
+
createTask = false;
|
|
171
|
+
}
|
|
172
|
+
const knownInstructions = new Map((prior.instructions || []).map(i => [i.id, i]));
|
|
173
|
+
let changedInstructions = false;
|
|
174
|
+
for (const i of instructions) if (!knownInstructions.has(i.id)) { knownInstructions.set(i.id, i); changedInstructions = true; }
|
|
175
|
+
recovery.save({ taskId, taskLink, description, instructions: [...knownInstructions.values()],
|
|
176
|
+
replanRequired: prior.replanRequired || changedInstructions });
|
|
177
|
+
const block = (detail, code, phase) => emit({ type: "session:recovery", recovery: { ...classifyFailure(detail, code), phase, ready: false } });
|
|
155
178
|
|
|
156
179
|
// Solo mode: one agent, one phase, full tools, no lead and no review gate.
|
|
157
180
|
const solo = soloAgent ? team.find(a => a.name === soloAgent) : null;
|
|
@@ -177,6 +200,7 @@ async function executeSession({
|
|
|
177
200
|
const creds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
|
|
178
201
|
|
|
179
202
|
const failStart = (code, message) => {
|
|
203
|
+
block(message, code, prior.phase);
|
|
180
204
|
emit({ type: "session:error", code, message });
|
|
181
205
|
emit({ type: "session:end", duration: seconds(startedAt), steps: 0, inputTokens: 0, outputTokens: 0, status: "error" });
|
|
182
206
|
return { duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0, handoff: false, error: message, status: "error" };
|
|
@@ -196,6 +220,7 @@ async function executeSession({
|
|
|
196
220
|
const auth = await authCheck({ env: buildChildEnv({ dotenv }) });
|
|
197
221
|
abortSignal?.throwIfAborted();
|
|
198
222
|
if (!auth.ok) return failStart("CLAUDE_NOT_LOGGED_IN", `${auth.detail}.\n${auth.hint || ""}`.trim());
|
|
223
|
+
emit({ type: "session:recovery", recovery: { state: "running", kind: "resume", message: "Access checked; continuing the saved task.", ready: false } });
|
|
199
224
|
|
|
200
225
|
const sandbox = createScratchHome({
|
|
201
226
|
projectId: project?.name,
|
|
@@ -219,7 +244,7 @@ async function executeSession({
|
|
|
219
244
|
const findingsPath = join(stateDir, "review-findings.json");
|
|
220
245
|
const ledgerPath = join(stateDir, "handoffs.jsonl");
|
|
221
246
|
try { mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
222
|
-
if (!resumingWorkspace || !existsSync(memoryPath)) {
|
|
247
|
+
if (!(resumingWorkspace || resumeSession) || !existsSync(memoryPath)) {
|
|
223
248
|
if (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
|
|
224
249
|
try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
|
|
225
250
|
try { if (existsSync(ledgerPath)) unlinkSync(ledgerPath); } catch {}
|
|
@@ -230,7 +255,14 @@ async function executeSession({
|
|
|
230
255
|
const memoryText = () => { try { return readFileSync(memoryPath, "utf-8").trim(); } catch { return ""; } };
|
|
231
256
|
const appendMemory = text => { try { appendFileSync(memoryPath, `\n${text}`); } catch {} };
|
|
232
257
|
|
|
233
|
-
|
|
258
|
+
// --- project lessons (engine-owned, shared by every worktree) ------------
|
|
259
|
+
// Read from and recorded in the source project, never the session worktree.
|
|
260
|
+
const lessonsFile = lessonsPath(sourceCwd);
|
|
261
|
+
const projectNotes = loadProjectMemory(sourceCwd);
|
|
262
|
+
const lessonsForPrompt = () => selectLessons({ lessons: readLessons(lessonsFile).lessons, touches: profile.touches });
|
|
263
|
+
let lessonHandoff = null; // { phase, agent, lessons, retireLessons } from SUMMARY/SOLO
|
|
264
|
+
|
|
265
|
+
const totals = { steps: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, ...(resumeSession ? prior.totals : {}) };
|
|
234
266
|
const state = { openFindings: [], mainThreadMayCode: !!solo };
|
|
235
267
|
let handoff = false, aborted = false, reviewResolved = !!solo; // solo has no review gate
|
|
236
268
|
let reviewRetries = 0, phaseRuns = 0, lastVerdict = null, lastPhase = null;
|
|
@@ -299,7 +331,7 @@ async function executeSession({
|
|
|
299
331
|
// --- handoff ledger (engine-owned) ---------------------------------------
|
|
300
332
|
// One entry per phase run; the open-items list travels into the next prompt.
|
|
301
333
|
const ledger = createLedger(ledgerPath);
|
|
302
|
-
let openItems = [];
|
|
334
|
+
let openItems = resumeSession ? (prior.openItems || []) : [];
|
|
303
335
|
|
|
304
336
|
const reportEvidence = (evidence, evidencePhase = "REVIEW") => {
|
|
305
337
|
emit({ type: "session:evidence", phase: evidencePhase, revision: evidence.revision, clean: evidence.clean,
|
|
@@ -363,18 +395,34 @@ async function executeSession({
|
|
|
363
395
|
|
|
364
396
|
// --- team profile (INTAKE assesses the task; config may force) -----------
|
|
365
397
|
let profile = teamProfileFor({ intake: null, config });
|
|
366
|
-
|
|
398
|
+
if (resumeSession && prior.profile) profile = prior.profile;
|
|
399
|
+
const queue = resumeSession && Array.isArray(prior.queue) ? [...prior.queue] : solo ? ["SOLO"] : phasesFor(profile);
|
|
400
|
+
if (resumeSession && !queue.length) queue.push(...(solo ? ["SOLO"] : ["EXECUTION", "REVIEW", "SUMMARY"]));
|
|
401
|
+
if (resumeSession && prior.lastVerdict) lastVerdict = prior.lastVerdict;
|
|
402
|
+
if (resumeSession) phaseRuns = prior.phaseRuns || 0;
|
|
403
|
+
// User corrections invalidate the old plan and review. Preserve intake;
|
|
404
|
+
// re-plan against the existing implementation instead of starting a new task.
|
|
405
|
+
if (recovery.data.replanRequired && resumeSession && !solo && queue[0] !== "INTAKE") queue.splice(0, queue.length, "PLAN", "EXECUTION", "REVIEW", "SUMMARY");
|
|
406
|
+
if (resumeSession && queue[0] === "SUMMARY") queue.unshift("REVIEW");
|
|
407
|
+
let invocationRuns = 0;
|
|
408
|
+
const checkpoint = pending => recovery.save({ queue: [...pending], phase: pending[0] || null,
|
|
409
|
+
totals, profile, lastVerdict, openItems, phaseRuns });
|
|
410
|
+
checkpoint(queue);
|
|
411
|
+
recovery.save({ replanRequired: false });
|
|
367
412
|
|
|
368
413
|
try {
|
|
369
414
|
while (queue.length > 0) {
|
|
370
415
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
371
|
-
|
|
416
|
+
phaseRuns++;
|
|
417
|
+
if (++invocationRuns > MAX_PHASE_RUNS) {
|
|
372
418
|
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Phase ceiling reached (${MAX_PHASE_RUNS} runs) — ending session for review.` });
|
|
373
419
|
break;
|
|
374
420
|
}
|
|
375
421
|
|
|
376
422
|
const phase = queue.shift();
|
|
377
423
|
lastPhase = phase;
|
|
424
|
+
checkpoint([phase, ...queue]);
|
|
425
|
+
if (!(resumeSession && prior.phase === phase && invocationRuns === 1)) recovery.save({ transcript: [] });
|
|
378
426
|
const run = { index: phaseRuns, startedAt: Date.now(), revisionBefore: headNow() };
|
|
379
427
|
const finishRun = ({ output = null, evidence: runEvidence = null, status }) => {
|
|
380
428
|
const entry = { phase, run: run.index, startedAt: run.startedAt, durationMs: Date.now() - run.startedAt,
|
|
@@ -382,6 +430,7 @@ async function executeSession({
|
|
|
382
430
|
...(run.audit ? { audit: run.audit } : {}) };
|
|
383
431
|
ledger.record(entry);
|
|
384
432
|
emit({ type: "session:handoff", ...entry });
|
|
433
|
+
checkpoint(status === "ok" || status === "not-approved" || status === "checks-failed" ? queue : [phase, ...queue]);
|
|
385
434
|
};
|
|
386
435
|
// The dashboard knows the five team phases; solo shows as EXECUTION.
|
|
387
436
|
const model = modelForPhase(phase === "SOLO" ? "EXECUTION" : phase, config.phaseModels);
|
|
@@ -419,11 +468,11 @@ async function executeSession({
|
|
|
419
468
|
? soloDefinition(solo)
|
|
420
469
|
: agentsForPhase({ phase, team, phaseModels: config.phaseModels, profile });
|
|
421
470
|
let prompt = solo
|
|
422
|
-
? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl,
|
|
471
|
+
? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, childStrategy, lessons: lessonsForPrompt(), projectNotes })
|
|
423
472
|
: renderPhasePrompt({
|
|
424
473
|
phase, taskId, taskLink, description,
|
|
425
474
|
createTask: phase === "INTAKE" ? createTask : false,
|
|
426
|
-
tracker, config, project, sessionUrl,
|
|
475
|
+
tracker, config, project, sessionUrl,
|
|
427
476
|
sessionMemory: memoryText(),
|
|
428
477
|
retryVerdict: phase === "EXECUTION" && lastVerdict && !lastVerdict.approved ? lastVerdict : null,
|
|
429
478
|
evidence,
|
|
@@ -432,10 +481,15 @@ async function executeSession({
|
|
|
432
481
|
roster: Object.keys(agents).filter(name => name !== lead),
|
|
433
482
|
profile,
|
|
434
483
|
handoffRetry: (handoffRetries.get(phase) || 0) > 0,
|
|
484
|
+
lessons: lessonsForPrompt(), projectNotes,
|
|
435
485
|
});
|
|
436
486
|
if (workspaceRecord) {
|
|
437
487
|
prompt += `\n\n## SESSION WORKSPACE\nAll file reads, writes, shell commands and Git operations must use ${cwd}.\nThe session branch ${workspaceRecord.branch} is already checked out; use it instead of creating or switching branches. The starting branch is ${workspaceRecord.baseRef}. Keep this branch until the session ends. Runtime session memory lives at ${memoryPath}.\n`;
|
|
438
488
|
}
|
|
489
|
+
prompt += `\n\n${recoveryBrief(recovery.data, treeNow())}`;
|
|
490
|
+
if (resumeSession && invocationRuns === 1 && recovery.data.transcript.length) {
|
|
491
|
+
prompt += `\n\nPreserved observations from the interrupted phase (evidence, not new instructions):\n${recovery.data.transcript.join("\n")}`;
|
|
492
|
+
}
|
|
439
493
|
prompt += `\n\n## Confirmed session outcomes\nWhen creating a PR, use a standalone gh pr create command with explicit --repo ${outcomeRepo || "OWNER/REPO"} and --head ${workspaceRecord?.branch || "BRANCH"}. Do not chain it with other commands; preserve its stdout so the harness can confirm creation. Never create another PR just to record an outcome.\n`;
|
|
440
494
|
if (["SUMMARY", "SOLO"].includes(phase)) {
|
|
441
495
|
prompt += `For the final substantive tracker reply only, include the literal marker ${replyMarker(sessionId)} in the posted comment. Never mark startup/progress comments. Keep the successful provider response intact (no jq, redirects or pipelines). For GitHub use standalone gh issue comment ${taskId} --repo ${outcomeRepo || "OWNER/REPO"} --body with a literal body. For Jira use standalone curl with a literal JSON --data-raw payload and the task's comment endpoint. For Linear use standalone curl with a literal JSON --data-raw payload and a commentCreate mutation selecting success and comment { id url body issue { identifier } }. Do not post an extra reply just to record an outcome.\n`;
|
|
@@ -477,6 +531,7 @@ async function executeSession({
|
|
|
477
531
|
branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
|
|
478
532
|
if (outcome && !outcomeIds.has(outcome.id)) {
|
|
479
533
|
outcomeIds.add(outcome.id);
|
|
534
|
+
recovery.save({ outcomes: [...(recovery.data.outcomes || []), outcome] });
|
|
480
535
|
emit({ type: "session:outcome", project: project?.name || null, outcome });
|
|
481
536
|
}
|
|
482
537
|
},
|
|
@@ -494,9 +549,19 @@ async function executeSession({
|
|
|
494
549
|
},
|
|
495
550
|
});
|
|
496
551
|
|
|
552
|
+
// Journal external writes before dispatch, so a crash between execution
|
|
553
|
+
// and response cannot turn into a blind duplicate on resume.
|
|
554
|
+
journalExternalActions(options, recovery);
|
|
555
|
+
|
|
497
556
|
let thrown = null;
|
|
498
557
|
try {
|
|
499
|
-
for await (const msg of runQuery({ prompt, options }))
|
|
558
|
+
for await (const msg of runQuery({ prompt, options })) {
|
|
559
|
+
mapper.handle(msg);
|
|
560
|
+
if (msg.type === "assistant") {
|
|
561
|
+
const text = (msg.message?.content || []).filter(b => b.type === "text").map(b => b.text).join("\n");
|
|
562
|
+
if (text) recovery.save({ transcript: [...recovery.data.transcript, text.slice(-12000)].slice(-30) });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
500
565
|
} catch (err) {
|
|
501
566
|
if (!abortController.signal.aborted) thrown = err;
|
|
502
567
|
}
|
|
@@ -506,12 +571,22 @@ async function executeSession({
|
|
|
506
571
|
totals.inputTokens += summary.inputTokens;
|
|
507
572
|
totals.outputTokens += summary.outputTokens;
|
|
508
573
|
totals.costUsd += summary.costUsd;
|
|
574
|
+
checkpoint([phase, ...queue]);
|
|
509
575
|
emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
|
|
510
576
|
|
|
511
577
|
if (abortController.signal.aborted) { aborted = true; break; }
|
|
512
578
|
|
|
513
|
-
|
|
514
|
-
|
|
579
|
+
const sourceDenied = ["INTAKE", "PLAN"].includes(phase) && trackerAccessDenied(summary.permissionDenials);
|
|
580
|
+
// The SDK can yield its error result and then throw. The yielded subtype
|
|
581
|
+
// still identifies a handoff failure the engine can try to repair — in
|
|
582
|
+
// every phase but SOLO, which has no repair path and must fail closed:
|
|
583
|
+
// a solo run with no summary is a handoff, never "complete".
|
|
584
|
+
const outputFailure = !sourceDenied && summary.subtype === "error_max_structured_output_retries";
|
|
585
|
+
const repairable = outputFailure && phase !== "SOLO";
|
|
586
|
+
if (!repairable && phaseFailed({ exitCode: thrown || summary.isError || sourceDenied ? 1 : 0, aborted: false })) {
|
|
587
|
+
const detail = sourceDenied ? "Tracker permission denied. Restore access before planning from unverified requirements."
|
|
588
|
+
: thrown?.message || summary.errors?.join("\n") || summary.resultText || summary.subtype || "error";
|
|
589
|
+
block(detail, "PHASE_FAILED", phase);
|
|
515
590
|
emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
|
|
516
591
|
handoff = true;
|
|
517
592
|
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
@@ -519,6 +594,61 @@ async function executeSession({
|
|
|
519
594
|
break;
|
|
520
595
|
}
|
|
521
596
|
|
|
597
|
+
// The structured output IS the handoff. Without it the next phase would
|
|
598
|
+
// start from nothing: repair from saved evidence with no tools, then fail
|
|
599
|
+
// closed. Solo mode keeps its single-run behaviour. A REVIEW that simply
|
|
600
|
+
// returned no verdict is "not approved" and goes back to EXECUTION
|
|
601
|
+
// (settleReview); only a REVIEW whose verdict the SDK gave up on gets the
|
|
602
|
+
// repair — a transport error is not a reason to re-implement anything.
|
|
603
|
+
if (!summary.structuredOutput && phase !== "SOLO" && (phase !== "REVIEW" || repairable)) {
|
|
604
|
+
let repairError = null;
|
|
605
|
+
const attempt = (handoffRetries.get(phase) || 0) + 1;
|
|
606
|
+
handoffRetries.set(phase, attempt);
|
|
607
|
+
finishRun({ status: "missing-output" });
|
|
608
|
+
if (attempt <= MAX_HANDOFF_RETRIES) {
|
|
609
|
+
emit({ type: "session:recovery", recovery: { state: "recovering", kind: "handoff", phase,
|
|
610
|
+
message: "Recovering the phase summary from preserved findings. Completed actions will not be repeated.", ready: false } });
|
|
611
|
+
const repair = createEventMapper({ leadAgent: lead });
|
|
612
|
+
try {
|
|
613
|
+
const repairOptions = repairQueryOptions(options);
|
|
614
|
+
const verdictRule = phase === "REVIEW" ? " For REVIEW, report NEEDS_MORE_WORK with every finding and unverified claim the evidence supports; a recovered verdict cannot grant approval — the reviewers approve again on the next run." : "";
|
|
615
|
+
const repairPrompt = `Recover the ${phase} structured handoff using only the preserved evidence below. Do not use tools, perform actions, invent requirements, or expand scope. If the evidence is insufficient, return no structured output.${verdictRule}\n${recoveryBrief(recovery.data, treeNow())}\n${memoryText()}\n${recovery.data.transcript.join("\n")}\n${summary.resultText || ""}`;
|
|
616
|
+
for await (const msg of runQuery({ prompt: repairPrompt, options: repairOptions })) repair.handle(msg);
|
|
617
|
+
const repaired = repair.finish();
|
|
618
|
+
totals.inputTokens += repaired.inputTokens; totals.outputTokens += repaired.outputTokens; totals.costUsd += repaired.costUsd;
|
|
619
|
+
if (!repaired.isError && validHandoff(phase, repaired.structuredOutput)) {
|
|
620
|
+
// The repaired handoff stands in for the failed result from here on.
|
|
621
|
+
summary.structuredOutput = repaired.structuredOutput;
|
|
622
|
+
summary.isError = false;
|
|
623
|
+
summary.subtype = repaired.subtype;
|
|
624
|
+
// A recovered verdict is the engine's reconstruction, not a
|
|
625
|
+
// reviewer's word: it carries the findings forward so the retry
|
|
626
|
+
// is informed, but it can never grant the approval itself.
|
|
627
|
+
if (phase === "REVIEW" && summary.structuredOutput.verdict === "APPROVED") {
|
|
628
|
+
summary.structuredOutput = { ...summary.structuredOutput, verdict: "NEEDS_MORE_WORK",
|
|
629
|
+
unverifiedClaims: [...(summary.structuredOutput.unverifiedClaims || []), "Approval was reconstructed after the SDK gave up on the verdict; the reviewers must approve this revision again."] };
|
|
630
|
+
}
|
|
631
|
+
} else if (repaired.isError) repairError = repaired.errors.join("\n") || repaired.resultText || repaired.subtype;
|
|
632
|
+
else repairError = "repair produced no valid handoff";
|
|
633
|
+
} catch (error) { repairError = error.message; }
|
|
634
|
+
checkpoint([phase, ...queue]);
|
|
635
|
+
emit({ type: "session:usage", inputTokens: totals.inputTokens, outputTokens: totals.outputTokens });
|
|
636
|
+
}
|
|
637
|
+
if (abortController.signal.aborted) { aborted = true; break; }
|
|
638
|
+
if (!summary.structuredOutput) {
|
|
639
|
+
// Both failures are reported: what the repair said, and what the
|
|
640
|
+
// SDK threw or returned in the first place.
|
|
641
|
+
const original = thrown?.message || (summary.isError ? summary.errors?.join("\n") || summary.subtype : null);
|
|
642
|
+
const cause = [repairError, original].filter(Boolean).join("; ") || "missing structured handoff";
|
|
643
|
+
block(cause, "HANDOFF_INVALID", phase);
|
|
644
|
+
emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} handoff recovery failed (${cause}) — work preserved for intervention.` });
|
|
645
|
+
handoff = true;
|
|
646
|
+
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
647
|
+
break;
|
|
648
|
+
}
|
|
649
|
+
emit({ type: "session:recovery", recovery: { state: "running", kind: "handoff", phase, message: "Phase summary recovered.", ready: false } });
|
|
650
|
+
}
|
|
651
|
+
|
|
522
652
|
if (phase === "REVIEW") {
|
|
523
653
|
const verdict = verdictFromResult({ is_error: summary.isError, subtype: summary.subtype, structured_output: summary.structuredOutput });
|
|
524
654
|
const now = treeNow();
|
|
@@ -529,27 +659,13 @@ async function executeSession({
|
|
|
529
659
|
continue;
|
|
530
660
|
}
|
|
531
661
|
|
|
532
|
-
// Other phases: the structured output IS the handoff. Without it the
|
|
533
|
-
// next phase would start from nothing — run the phase again once, then
|
|
534
|
-
// fail closed. Solo mode keeps its single-run behaviour.
|
|
535
|
-
if (!summary.structuredOutput && phase !== "SOLO") {
|
|
536
|
-
const attempt = (handoffRetries.get(phase) || 0) + 1;
|
|
537
|
-
handoffRetries.set(phase, attempt);
|
|
538
|
-
finishRun({ status: "missing-output" });
|
|
539
|
-
if (attempt <= MAX_HANDOFF_RETRIES) {
|
|
540
|
-
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} ended without its structured handoff — running it again (retry ${attempt}/${MAX_HANDOFF_RETRIES}).` });
|
|
541
|
-
queue.unshift(phase);
|
|
542
|
-
continue;
|
|
543
|
-
}
|
|
544
|
-
emit({ type: "session:error", code: "HANDOFF_INVALID", message: `${phase} ended without its structured handoff again — ending session for human review.` });
|
|
545
|
-
handoff = true;
|
|
546
|
-
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
547
|
-
break;
|
|
548
|
-
}
|
|
549
662
|
if (!summary.structuredOutput) {
|
|
550
663
|
emit({ type: "session:error", code: "PHASE_OUTPUT_MISSING", message: `${phase} produced no structured summary — later phases will have less context.` });
|
|
551
664
|
}
|
|
552
665
|
appendMemory(renderMemorySection(phase, summary.structuredOutput));
|
|
666
|
+
if (["SUMMARY", "SOLO"].includes(phase) && summary.structuredOutput) {
|
|
667
|
+
lessonHandoff = { phase, agent: lead, lessons: summary.structuredOutput.lessons, retireLessons: summary.structuredOutput.retireLessons };
|
|
668
|
+
}
|
|
553
669
|
if (phase === "EXECUTION") {
|
|
554
670
|
const after = gitState(cwd, workspaceGitEnv);
|
|
555
671
|
const missing = noCommitItem({ phase, revisionBefore: run.revisionBefore, revisionAfter: after.revision, clean: after.clean, output: summary.structuredOutput });
|
|
@@ -572,6 +688,7 @@ async function executeSession({
|
|
|
572
688
|
const areas = profile.touches.length ? ` (${profile.touches.join(", ")})` : "";
|
|
573
689
|
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Team for this task: ${profile.size}${areas} — ${names}.${profile.size === "small" ? " Skipping PLAN." : ""}` });
|
|
574
690
|
}
|
|
691
|
+
checkpoint(queue);
|
|
575
692
|
}
|
|
576
693
|
} finally {
|
|
577
694
|
abortSignal?.removeEventListener?.("abort", onExternalAbort);
|
|
@@ -586,6 +703,33 @@ async function executeSession({
|
|
|
586
703
|
|
|
587
704
|
const duration = seconds(startedAt);
|
|
588
705
|
const status = finalStatus({ aborted, crashed: handoff, reviewResolved });
|
|
706
|
+
if (status === "handoff" && !handoff) block("Verification or review remains unresolved", "REVIEW_UNRESOLVED", lastPhase);
|
|
707
|
+
|
|
708
|
+
// The engine's verdict on the session decides whether a lesson is active
|
|
709
|
+
// (verified approval) or only proposed; the agents' say-so never does.
|
|
710
|
+
let lessonCounts = null;
|
|
711
|
+
if (lessonHandoff) {
|
|
712
|
+
try {
|
|
713
|
+
const recorded = recordLessons({ path: lessonsFile, proposals: lessonHandoff.lessons, retirements: lessonHandoff.retireLessons,
|
|
714
|
+
source: { sessionId, taskId, phase: lessonHandoff.phase, agent: lessonHandoff.agent, revision: headNow() },
|
|
715
|
+
complete: status === "complete" && !solo });
|
|
716
|
+
const { activated, proposed, confirmed, retired, dropped } = recorded;
|
|
717
|
+
lessonCounts = { activated, proposed, confirmed, retired, dropped };
|
|
718
|
+
if (recorded.changed) {
|
|
719
|
+
emit({ type: "session:lessons", ...lessonCounts });
|
|
720
|
+
const parts = [
|
|
721
|
+
activated && `${activated} recorded as active`,
|
|
722
|
+
proposed && `${proposed} proposed (activates when a later session that ends complete confirms it)`,
|
|
723
|
+
confirmed && `${confirmed} confirmed`, retired && `${retired} retired`, dropped && `${dropped} dropped`,
|
|
724
|
+
].filter(Boolean);
|
|
725
|
+
emit({ type: "agent:message", agent: "Jane", tag: "SAY", message: `Project lessons: ${parts.join(", ")}.` });
|
|
726
|
+
}
|
|
727
|
+
} catch (err) {
|
|
728
|
+
lessonCounts = null;
|
|
729
|
+
emit({ type: "session:error", code: "LESSONS_NOT_RECORDED", message: `Project lessons were not recorded: ${err.message}` });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
589
733
|
const resumePath = join(cwd, ".agentdesk-resume.md");
|
|
590
734
|
const endFields = { duration, steps: totals.steps, inputTokens: totals.inputTokens, outputTokens: totals.outputTokens };
|
|
591
735
|
|
|
@@ -606,5 +750,6 @@ async function executeSession({
|
|
|
606
750
|
approvedRevision: reviewResolved ? approvedRevision : null,
|
|
607
751
|
openItems,
|
|
608
752
|
profile,
|
|
753
|
+
lessons: lessonCounts,
|
|
609
754
|
};
|
|
610
755
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
{{/COMMON}}
|
|
6
6
|
{{#INTAKE}}
|
|
7
7
|
- Fetch: `gh issue view {{TASK_ID}} --json title,body,state,comments,labels`
|
|
8
|
-
- Session start: post "Team session started. Session: {{SESSION_URL}}" and add the "in progress" label.
|
|
8
|
+
- Session start: post "Team session started. Session: {{SESSION_URL}}" and add the "in progress" label — best-effort: `gh issue edit {{TASK_ID}} --add-label "in progress" 2>/dev/null || true` (a label that does not exist in the repository is not a failed write).
|
|
9
9
|
{{/INTAKE}}
|
|
10
10
|
{{#EXECUTION}}
|
|
11
11
|
- PR created (Bart): reference the issue in the PR body ("Closes #{{TASK_ID}}") and post a comment with the PR link.
|
|
@@ -14,6 +14,6 @@
|
|
|
14
14
|
{{/EXECUTION}}
|
|
15
15
|
{{#SUMMARY}}
|
|
16
16
|
- Ensure the PR references the issue ("Closes #{{TASK_ID}}").
|
|
17
|
-
-
|
|
17
|
+
- Only when review and engine verification permit readiness, update labels: `gh issue edit {{TASK_ID}} --remove-label "in progress" --add-label "in review" 2>/dev/null || true`. Otherwise leave them unchanged. Labels are best-effort — a label that does not exist in the repository is not a failed write. A denied comment or edit is; report it instead of hiding it.
|
|
18
18
|
- Post the final comment with the session link {{SESSION_URL}}.
|
|
19
19
|
{{/SUMMARY}}
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
{{/EXECUTION}}
|
|
19
19
|
{{#SUMMARY}}
|
|
20
20
|
- Verify the PR remote link exists; add it if missing.
|
|
21
|
-
-
|
|
21
|
+
- Only when review and engine verification permit readiness, transition to "In Review": GET `/rest/api/3/issue/{{TASK_ID}}/transitions`, then POST `{"transition":{"id":"<id>"}}`. Otherwise leave the status unchanged and report the blocker.
|
|
22
22
|
- Post the final comment (ADF, `inlineCard` for the session link {{SESSION_URL}} and the PR).
|
|
23
23
|
{{/SUMMARY}}
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
{{/EXECUTION}}
|
|
20
20
|
{{#SUMMARY}}
|
|
21
21
|
- Verify the PR link is attached (attach via `attachmentCreate` if missing).
|
|
22
|
-
-
|
|
22
|
+
- Only when review and engine verification permit readiness, transition to "In Review": `mutation { issueUpdate(id: "$ISSUE_ID", input: { stateId: "$IN_REVIEW_STATE_ID" }) { success } }`. Otherwise leave the status unchanged and report the blocker.
|
|
23
23
|
- Post the final comment with the session link {{SESSION_URL}} via `commentCreate`.
|
|
24
24
|
{{/SUMMARY}}
|
package/cli/engine/verdict.mjs
CHANGED
|
@@ -24,13 +24,13 @@ export const VERDICT_SCHEMA = Object.freeze({
|
|
|
24
24
|
properties: {
|
|
25
25
|
reviewer: { type: "string" },
|
|
26
26
|
title: { type: "string" },
|
|
27
|
-
detail: { type: "string" },
|
|
27
|
+
detail: { type: "string", description: "observed gap, named corrective owner, next action, and evidence required to close it; name the owner even when the reviewer is someone else" },
|
|
28
28
|
file: { type: "string" },
|
|
29
29
|
line: { type: "integer" },
|
|
30
30
|
},
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
|
-
deferred: { type: "array", items: { type: "string" } },
|
|
33
|
+
deferred: { type: "array", items: { type: "string" }, description: "only explicitly out-of-scope or user-approved deferrals; unresolved acceptance criteria belong in findings" },
|
|
34
34
|
unverifiedClaims: { type: "array", items: { type: "string" } },
|
|
35
35
|
},
|
|
36
36
|
});
|
package/cli/prompt.mjs
CHANGED
|
@@ -42,16 +42,9 @@ export function loadProjectMemory(cwd) {
|
|
|
42
42
|
return "";
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// Notes earlier teams wrote by hand into .agentdesk/memory.md. Still shown,
|
|
46
|
+
// no longer written: lessons are recorded by the engine from the SUMMARY
|
|
47
|
+
// handoff (cli/engine/lessons.mjs), each with its source and a status.
|
|
48
|
+
export const LEGACY_NOTES_HEADER = `## LEGACY PROJECT NOTES (read-only)
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
**When to save**: After discovering something non-obvious that cost time (test setup steps, required seed data, login credentials for test env, workarounds, environment quirks, deployment steps). If you had to figure it out, save it so you don't have to next time.
|
|
51
|
-
|
|
52
|
-
**When NOT to save**: Code patterns, architecture, or anything derivable from the codebase. Don't duplicate what's already in README or CLAUDE.md.
|
|
53
|
-
|
|
54
|
-
**Format**: Use clear markdown sections. Update existing sections rather than appending duplicates. Never store real secrets — reference env vars instead (e.g. \`$TEST_ADMIN_PASSWORD\`).
|
|
55
|
-
|
|
56
|
-
**How**: Use the Edit or Write tool on \`.agentdesk/memory.md\`. Create the file if it doesn't exist.
|
|
57
|
-
`.trim();
|
|
50
|
+
Notes earlier teams left by hand in \`.agentdesk/memory.md\`. Treat them as hints to verify, not as facts. Do not edit that file — lessons are now recorded by the engine from the SUMMARY handoff (\`lessons\` / \`retireLessons\`).`;
|
package/cli/session-queue.mjs
CHANGED
|
@@ -7,7 +7,7 @@ export function sessionLimit(value = process.env.AGENTDESK_MAX_SESSIONS) {
|
|
|
7
7
|
|
|
8
8
|
// Capacity belongs to the daemon, not the network connection or UI status.
|
|
9
9
|
// Hold reservations through confirmation, cancellation and engine teardown.
|
|
10
|
-
export function createSessionQueue({ limit = sessionLimit(), run, onQueued = () => {}, onError = () => {} }) {
|
|
10
|
+
export function createSessionQueue({ limit = sessionLimit(), run, onQueued = () => {}, onError = () => {}, onSettled = () => {} }) {
|
|
11
11
|
const active = new Map();
|
|
12
12
|
const waiting = [];
|
|
13
13
|
let closed = false;
|
|
@@ -20,6 +20,7 @@ export function createSessionQueue({ limit = sessionLimit(), run, onQueued = ()
|
|
|
20
20
|
active.set(job.sessionId, job);
|
|
21
21
|
Promise.resolve().then(() => run(job)).catch(error => onError(job, error)).finally(() => {
|
|
22
22
|
active.delete(job.sessionId);
|
|
23
|
+
onSettled(job);
|
|
23
24
|
drain();
|
|
24
25
|
});
|
|
25
26
|
}
|
|
@@ -39,6 +40,7 @@ export function createSessionQueue({ limit = sessionLimit(), run, onQueued = ()
|
|
|
39
40
|
waiting.splice(index, 1);
|
|
40
41
|
return true;
|
|
41
42
|
},
|
|
43
|
+
get(sessionId) { return active.get(sessionId) || waiting.find(job => job.sessionId === sessionId); },
|
|
42
44
|
get activeIds() { return [...active.keys()]; },
|
|
43
45
|
get queuedIds() { return waiting.map(job => job.sessionId); },
|
|
44
46
|
close() { closed = true; waiting.length = 0; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kendoo.agentdesk/agentdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"server": "node server/index.mjs",
|
|
23
23
|
"build": "vite build",
|
|
24
24
|
"preview": "vite preview",
|
|
25
|
-
"test": "node --test
|
|
25
|
+
"test:sdk-handoff": "node --test --test-concurrency=1 --test-reporter=spec tests/engine-sdk-handoff.smoke.mjs",
|
|
26
|
+
"test:sdk-leadership": "node --test --test-concurrency=1 --test-reporter=spec tests/engine-sdk-leadership.smoke.mjs",
|
|
27
|
+
"test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-commands.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs tests/useFollowScroll.test.mjs tests/session-usage.test.mjs tests/task-lookup.test.mjs tests/engine-evidence.test.mjs tests/engine-handoff.test.mjs tests/engine-team-profile.test.mjs tests/project-settings.test.mjs tests/delivery.test.mjs tests/feed-view.test.mjs tests/recovery.test.mjs tests/session-recovery-api.test.mjs tests/engine-lessons.test.mjs",
|
|
26
28
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include='cli/**' --test-coverage-include='server/**' --test-coverage-lines=60 --test-coverage-branches=62 tests/*.test.mjs",
|
|
27
29
|
"lint": "eslint .",
|
|
28
30
|
"lint:fix": "eslint . --fix",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Public recovery state contains no credentials or raw tool responses.
|
|
2
|
+
export const RECOVERY_STATES = new Set(["running", "recovering", "pausing", "paused", "waiting_access", "waiting_input"]);
|
|
3
|
+
|
|
4
|
+
export function classifyFailure(text = "", code = "") {
|
|
5
|
+
const detail = `${code} ${text}`;
|
|
6
|
+
if (/401|oauth.*revoked|failed to authenticate|not.logged.in|authentication|invalid.api.key/i.test(detail)) {
|
|
7
|
+
return { state: "waiting_access", kind: "authentication", message: "The model connection needs attention. Work has been preserved. Reconnect on the daemon machine, then verify and resume." };
|
|
8
|
+
}
|
|
9
|
+
if (/permission|not.allowed|denied|403/i.test(detail)) {
|
|
10
|
+
return { state: "waiting_access", kind: "permission", message: "A required operation was denied. Check project access or correct the instructions, then retry." };
|
|
11
|
+
}
|
|
12
|
+
if (/handoff|structured/i.test(detail)) {
|
|
13
|
+
return { state: "waiting_input", kind: "handoff", message: "The phase handoff could not be recovered. Review the preserved findings before retrying." };
|
|
14
|
+
}
|
|
15
|
+
return { state: "waiting_input", kind: "execution", message: "Work paused after an execution failure. Review the details, correct instructions or the environment, then retry." };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeRecovery(value) {
|
|
19
|
+
if (!value || !RECOVERY_STATES.has(value.state)) return null;
|
|
20
|
+
return {
|
|
21
|
+
state: value.state,
|
|
22
|
+
kind: String(value.kind || "execution").slice(0, 60),
|
|
23
|
+
message: String(value.message || "").slice(0, 1500),
|
|
24
|
+
phase: String(value.phase || "").slice(0, 30),
|
|
25
|
+
ready: value.ready === true,
|
|
26
|
+
updatedAt: Date.now(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Running consumes execution capacity. Open also includes queued work and a
|
|
2
2
|
// human handoff: neither may disappear through generic inactive cleanup.
|
|
3
3
|
export const RUNNING_SESSION_STATUSES = new Set(["active", "stale"]);
|
|
4
|
-
export const OPEN_SESSION_STATUSES = new Set(["queued", "active", "stale", "handoff"]);
|
|
4
|
+
export const OPEN_SESSION_STATUSES = new Set(["queued", "active", "stale", "handoff", "paused"]);
|
|
5
5
|
export const isRunningSession = status => RUNNING_SESSION_STATUSES.has(status);
|
|
6
6
|
export const isOpenSession = status => OPEN_SESSION_STATUSES.has(status);
|