@anhlp/alp-runtime 0.0.0-beta.2 → 0.0.1-beta.3
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/claude-transport.d.ts +3 -0
- package/dist/claude-transport.js +59 -8
- package/dist/contracts.d.ts +2 -1
- package/dist/runtime-context.d.ts +1 -1
- package/dist/runtime-context.js +1 -1
- package/dist/runtime.js +244 -122
- package/dist/session-config.d.ts +24 -0
- package/dist/session-config.js +3 -3
- package/dist/session-state.d.ts +8 -1
- package/dist/tool-detail.js +4 -1
- package/dist/tools.d.ts +8 -0
- package/dist/tools.js +7 -5
- package/package.json +3 -3
- package/src/claude-transport.ts +64 -8
- package/src/contracts.ts +2 -2
- package/src/runtime-context.ts +2 -2
- package/src/runtime.ts +261 -115
- package/src/session-config.ts +5 -2
- package/src/session-state.ts +8 -1
- package/src/tool-detail.ts +4 -1
- package/src/tools.ts +6 -4
package/src/runtime.ts
CHANGED
|
@@ -56,6 +56,8 @@ const USER_REPLY_MS = 60_000;
|
|
|
56
56
|
|
|
57
57
|
/** What a waiting session hears when the user wrote to it. */
|
|
58
58
|
const USER_WROTE = 'The user just wrote to you; their message follows. Answer them first in a short message they can read (tool calls alone show them nothing), and steer an assignment with alp_send when their words change its brief. Your assignments keep running: then alp_wait again, or end your turn and ALP wakes you with their results.';
|
|
59
|
+
/** A wait cut short by the end of its turn: what it waited for goes on. */
|
|
60
|
+
const TURN_ENDED = 'Turn ended before the result arrived. Running assignments keep running; ALP wakes you with their results as mail.';
|
|
59
61
|
/** What a waiting session hears with a check-in, or mail from its requester or the user. */
|
|
60
62
|
const CHECKED_IN = 'Not a result: the assignment keeps running. Act on this mail first (a steer from your requester or the user\'s words may change what your assignments should do: pass that on with alp_send), then alp_wait again or end your turn.';
|
|
61
63
|
/** A session's native process is restarted at most RESTART_LIMIT times within RESTART_WINDOW_MS unless it makes progress meanwhile. */
|
|
@@ -206,11 +208,12 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
206
208
|
...(session.parent ? { parentId: session.parent } : {}),
|
|
207
209
|
...(session.toolCallId ? { toolCallId: session.toolCallId } : {}),
|
|
208
210
|
...(session.active ? { activeTurnId: session.active } : {}),
|
|
209
|
-
|
|
211
|
+
// turn.ended listeners persist the intent before terminal() finishes parking it.
|
|
212
|
+
...((session.parked?.reason ?? session.parkReason) ? { parked: session.parked?.reason ?? session.parkReason } : {}),
|
|
210
213
|
...(session.instructionsSha ? { instructionsSha: session.instructionsSha } : {}),
|
|
211
214
|
...(takesImages(session) ? { promptImages: true } : {}),
|
|
212
215
|
...(session.usage ? { usage: session.usage } : {}),
|
|
213
|
-
busy: !!(session.active || session.pending || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session)),
|
|
216
|
+
busy: !!(session.active || session.pending || session.parked || session.parkReason || session.children.size || session.assignments.size || hasActiveMail(session) || reviewing(session)),
|
|
214
217
|
};
|
|
215
218
|
}
|
|
216
219
|
|
|
@@ -238,7 +241,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
238
241
|
clearTimeout(waiter.timer);
|
|
239
242
|
waiter.resolve(null);
|
|
240
243
|
}
|
|
241
|
-
cancelQuestions(sessionId);
|
|
244
|
+
cancelQuestions(sessionId, true);
|
|
242
245
|
if (session.parent) sessions.get(session.parent)?.assignments.get(sessionId)?.ask?.resolve(toolResult(false, { error: 'Turn ended' }));
|
|
243
246
|
|
|
244
247
|
// Mail is acknowledged only by a turn that completed; otherwise it is delivered again.
|
|
@@ -465,7 +468,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
465
468
|
|
|
466
469
|
// A usage limit pauses its runtime; an assignment it stopped is parked to continue later.
|
|
467
470
|
if (state === 'failed' && LIMIT_ERRORS.includes(params.turn.error?.codexErrorInfo)) {
|
|
468
|
-
if (session.
|
|
471
|
+
if (session.role !== 'supervisor') session.parkReason = `the ${label(session.runtimeKind)} usage limit was reached`;
|
|
469
472
|
void limitReached(session, params.turn.error);
|
|
470
473
|
}
|
|
471
474
|
|
|
@@ -484,6 +487,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
484
487
|
|
|
485
488
|
session.closed = true;
|
|
486
489
|
cancelQuestions(sessionId);
|
|
490
|
+
approvalLedger.delete(sessionId);
|
|
487
491
|
releaseClaims(sessionId, session.mapping.agent.projectRoot);
|
|
488
492
|
|
|
489
493
|
await Promise.all(
|
|
@@ -624,7 +628,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
624
628
|
const boards = new Map<string, Promise<Pin[]>>();
|
|
625
629
|
/** The same boards once loaded, for synchronous status. */
|
|
626
630
|
const loadedBoards = new Map<string, Pin[]>();
|
|
627
|
-
const userQuestions = new Map<string, { question: UserQuestion; settle: (outcome: 'answered' | 'dismissed' | 'timeout' | 'canceled', answer?: string, reason?: string, result?: unknown) => void }>();
|
|
631
|
+
const userQuestions = new Map<string, { question: UserQuestion; persistent?: boolean; /** From alp_ask: the user's words in the chat end it (#116). */ asked?: boolean; settle: (outcome: 'answered' | 'dismissed' | 'timeout' | 'canceled', answer?: string, reason?: string, result?: unknown) => void }>();
|
|
628
632
|
let mailSequence = 0;
|
|
629
633
|
let watchdog: NodeJS.Timeout | undefined;
|
|
630
634
|
|
|
@@ -793,6 +797,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
793
797
|
: entry.event === 'task' ? `${entry.agent} ${TASK_PAST[entry.action] ?? entry.action} task ${entry.id} "${clip(entry.title, 120)}"${entry.status ? ` (now ${entry.status})` : ''}${entry.detail ? `: ${clip(entry.detail)}` : ''}`
|
|
794
798
|
: entry.event === 'epic.landed' ? `${entry.agent} closed ${entry.id} "${clip(entry.title, 120)}": ${entry.tasks} tasks, ${entry.reworked} reworked, ${entry.unverified?.length ?? 0} closed unverified`
|
|
795
799
|
: entry.event === 'recall' ? `${entry.agent} recalled ${entry.recalled} assignment ${entry.assignmentId}: ${clip(entry.question)}`
|
|
800
|
+
: entry.event === 'skill.approval' && !entry.saved ? `skill approval ${entry.approvalId} for ${entry.name}: not saved (${entry.error ?? entry.outcome})`
|
|
801
|
+
: entry.event === 'issue.approval' ? `issue approval ${entry.approvalId} in ${entry.repo}: ${entry.posted ? entry.url : entry.error ?? entry.outcome}`
|
|
796
802
|
: entry.event === 'issue' ? `${entry.action === 'create' ? `opened issue "${clip(entry.title, 200)}"` : `commented on issue #${entry.issue}`} in ${entry.repo} with the user's approval: ${entry.url}`
|
|
797
803
|
: entry.event?.startsWith('worktree.') ? `${entry.event.slice('worktree.'.length)} worktree of ${entry.assignmentId} (${entry.branch})${entry.status ? `: ${entry.status}` : ''}`
|
|
798
804
|
: undefined;
|
|
@@ -1253,20 +1259,36 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1253
1259
|
}
|
|
1254
1260
|
|
|
1255
1261
|
function continueParked(sessionId: string, session: Session) {
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1262
|
+
void enqueue(async () => {
|
|
1263
|
+
if (session.closed || session.active || session.pending || !session.parked || pauseOf(session.runtimeKind)) return;
|
|
1264
|
+
const parked = session.parked;
|
|
1265
|
+
const { reason, prompt } = parked;
|
|
1266
|
+
session.parked = undefined;
|
|
1267
|
+
session.wakeHeld = false;
|
|
1268
|
+
session.lastActivity = Date.now();
|
|
1269
|
+
const parent = session.parent ? sessions.get(session.parent) : undefined;
|
|
1270
|
+
const assignment = parent?.assignments.get(sessionId);
|
|
1271
|
+
runLog(rootOf(sessionId), { event: 'assignment.resumed', assignmentId: sessionId, agent: session.mapping.agent.name });
|
|
1272
|
+
if (assignment) emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'running') });
|
|
1273
|
+
try {
|
|
1274
|
+
await startPrompt(sessionId, {
|
|
1275
|
+
clientMessageId: `alp-resume-${randomUUID()}`,
|
|
1276
|
+
delivery: 'auto',
|
|
1277
|
+
content: [{ type: 'text', text: prompt ?? (session.parent
|
|
1278
|
+
? `ALP resumed this assignment; it had stopped because ${reason}. Continue where you left off: your earlier work in this session and its files are intact. Finish with alp_handoff as before.`
|
|
1279
|
+
: `ALP resumed this session; it had stopped because ${reason}. Continue the user's request where you left off: your earlier work in this thread and its files are intact.`) }],
|
|
1280
|
+
}, session.parent ? 'assignment' : 'wake');
|
|
1281
|
+
session.resumedTurn = session.active;
|
|
1282
|
+
emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
if (!session.closed && !session.active) {
|
|
1285
|
+
session.parked = parked;
|
|
1286
|
+
emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
|
|
1287
|
+
if (assignment) emit(session.parent!, { type: 'assignment', assignment: assignmentSnapshot(assignment, 'parked') });
|
|
1288
|
+
}
|
|
1289
|
+
notice('warning', `Could not resume ${session.mapping.agent.name}: ${errorData(error).message}`);
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1270
1292
|
}
|
|
1271
1293
|
|
|
1272
1294
|
/** When a limit resets, from what the runtime last reported: the latest reset of a window that is used up. */
|
|
@@ -1287,8 +1309,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1287
1309
|
/** A turn failed on a usage limit: pause that runtime for delegation and wakes, and tell the user. */
|
|
1288
1310
|
async function limitReached(session: Session, error: any) {
|
|
1289
1311
|
const kind = session.runtimeKind;
|
|
1290
|
-
if (
|
|
1312
|
+
if (pauseOf(kind)) return;
|
|
1291
1313
|
const resetsAt = await limitReset(session, error);
|
|
1314
|
+
// A user's pause made while the provider lookup awaited remains theirs to lift.
|
|
1315
|
+
if (closed || session.closed || pauseOf(kind)) return;
|
|
1292
1316
|
const pause: Pause = { since: new Date().toISOString(), by: 'alpd', reason: `${label(kind)} usage limit reached`, ...(resetsAt ? { resetsAt } : {}) };
|
|
1293
1317
|
pauseRuntime(kind, pause);
|
|
1294
1318
|
const other = RUNTIMES.find(candidate => candidate !== kind && !pauseOf(candidate));
|
|
@@ -1478,10 +1502,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1478
1502
|
* Asks the user to approve a proposal and waits for the answer. Only an approving
|
|
1479
1503
|
* answer approves; a dismissal, a timeout or another answer does not.
|
|
1480
1504
|
*/
|
|
1481
|
-
async function confirm(sessionId: string, session: Session, body: string): Promise<{ approved: boolean; feedback?: string; outcome?: string }> {
|
|
1505
|
+
async function confirm(sessionId: string, session: Session, body: string, approval?: { id: string }): Promise<{ approved: boolean; feedback?: string; outcome?: string }> {
|
|
1482
1506
|
if ([...userQuestions.values()].some(pending => pending.question.sessionId === sessionId)) return { approved: false, outcome: 'busy', feedback: 'A question is already waiting for the user' };
|
|
1483
1507
|
const say = wordsFor(session);
|
|
1484
|
-
const result = await askUser(sessionId, session, undefined, body, [say.approve, say.reject]) as { contentItems: Array<{ text: string }> };
|
|
1508
|
+
const result = await askUser(sessionId, session, undefined, body, [say.approve, say.reject], approval) as { contentItems: Array<{ text: string }> };
|
|
1485
1509
|
let value: any;
|
|
1486
1510
|
try { value = JSON.parse(result.contentItems[0].text); } catch { value = {}; }
|
|
1487
1511
|
if (value.status !== 'answered') return { approved: false, outcome: value.status ?? 'canceled', ...(value.reason ? { feedback: value.reason } : {}) };
|
|
@@ -1489,18 +1513,25 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1489
1513
|
return APPROVALS.includes(answer.toLowerCase()) ? { approved: true } : { approved: false, outcome: 'answered', feedback: answer };
|
|
1490
1514
|
}
|
|
1491
1515
|
|
|
1492
|
-
const declined = (decision: { feedback?: string; outcome?: string }) => toolResult(true, {
|
|
1493
|
-
approved: false,
|
|
1494
|
-
outcome: decision.outcome,
|
|
1495
|
-
...(decision.feedback ? { feedback: decision.feedback } : {}),
|
|
1496
|
-
next: 'Nothing was saved or posted. Revise from the feedback and propose again, or drop it.',
|
|
1497
|
-
});
|
|
1498
|
-
|
|
1499
1516
|
const text = (value: unknown, limit: number) => typeof value === 'string' && !!value.trim() && value.length <= limit;
|
|
1500
1517
|
|
|
1518
|
+
// Approvals of alp_issue and alp_skill (#114, #116): the tool returns a pending id at once and the
|
|
1519
|
+
// decision comes as result mail. Draft identities stay until session close, so a replayed tool
|
|
1520
|
+
// call or turn never applies an approved draft twice. Interrupting a session cancels its approvals.
|
|
1521
|
+
const approvalCancellation = new WeakMap<Session, number>();
|
|
1522
|
+
const approvalLedger = new Map<string, Map<string, { approvalId: string; draftKey: string; result?: Record<string, unknown> }>>();
|
|
1523
|
+
const approvalsOf = (sessionId: string) => {
|
|
1524
|
+
let approvals = approvalLedger.get(sessionId);
|
|
1525
|
+
if (!approvals) approvalLedger.set(sessionId, approvals = new Map());
|
|
1526
|
+
return approvals;
|
|
1527
|
+
};
|
|
1528
|
+
const approvalCanceled = (session: Session, cancellation: number) => closed || session.closed || cancellation !== (approvalCancellation.get(session) ?? 0);
|
|
1529
|
+
|
|
1501
1530
|
async function skillTool(sessionId: string, session: Session, args: unknown) {
|
|
1531
|
+
const cancellation = approvalCancellation.get(session) ?? 0;
|
|
1532
|
+
args = structuredClone(args);
|
|
1502
1533
|
if (
|
|
1503
|
-
!plainObject(args, ['name', 'description', 'body', 'roles', 'lessons', 'replace']) ||
|
|
1534
|
+
!plainObject(args, ['name', 'description', 'body', 'roles', 'lessons', 'replace', 'requestId']) ||
|
|
1504
1535
|
typeof args.name !== 'string' || !/^[a-z0-9][a-z0-9-]{1,62}$/.test(args.name) ||
|
|
1505
1536
|
!text(args.description, 300) || !text(args.body, SKILL_BODY_CHARS) ||
|
|
1506
1537
|
!Array.isArray(args.roles) || !args.roles.length || args.roles.length > 10 || !args.roles.every((role: unknown) => typeof role === 'string' && /^[\w.-]+$/.test(role) && role !== '.' && role !== '..') ||
|
|
@@ -1509,14 +1540,25 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1509
1540
|
) {
|
|
1510
1541
|
return toolResult(false, { error: `A skill needs a name (lowercase letters, digits, hyphens), a one-line description, a body of at most ${SKILL_BODY_CHARS} characters, and the roles that get it` });
|
|
1511
1542
|
}
|
|
1512
|
-
if (!
|
|
1513
|
-
const
|
|
1543
|
+
if (args.requestId !== undefined && !text(args.requestId, 100)) return toolResult(false, { error: 'requestId must be a nonempty string of at most 100 characters' });
|
|
1544
|
+
const library = options.libraryDir;
|
|
1545
|
+
if (!library) return toolResult(false, { error: 'This host keeps no user skill library' });
|
|
1546
|
+
const known = new Set([...STARTER_ROLES, ...await discoverAgents(session.mapping.agent.projectRoot, { library, templates: options.templates })]);
|
|
1514
1547
|
const unknown = args.roles.filter((role: string) => !known.has(role));
|
|
1515
1548
|
if (unknown.length) return toolResult(false, { error: `Unknown roles: ${unknown.join(', ')}. Roles are ${[...known].join(', ')}` });
|
|
1516
|
-
const file = path.join(
|
|
1549
|
+
const file = path.join(library, 'skills', args.name, 'SKILL.md');
|
|
1550
|
+
const content = `---\nname: ${args.name}\ndescription: ${JSON.stringify(args.description.replace(/\s+/g, ' ').trim())}\n---\n\n${args.body.trim()}\n`;
|
|
1551
|
+
const lessons: string[] = (args.lessons ?? []).map((lesson: string) => lesson.replace(/\s+/g, ' ').trim());
|
|
1552
|
+
// A replay of a proposed draft returns its approval, before anything it changed is checked again.
|
|
1553
|
+
const draftKey = JSON.stringify(['skill', content, args.roles, lessons, !!args.replace]);
|
|
1554
|
+
const key = `skill:${args.requestId ?? draftKey}`;
|
|
1555
|
+
const approvals = approvalsOf(sessionId);
|
|
1556
|
+
const previous = approvals.get(key);
|
|
1557
|
+
if (previous && previous.draftKey !== draftKey) return toolResult(false, { error: 'requestId was already used for a different draft' });
|
|
1558
|
+
if (previous) return toolResult(true, previous.result ?? { status: 'pending_approval', approvalId: previous.approvalId, file });
|
|
1517
1559
|
const existing = await readFile(file, 'utf8').catch(() => undefined);
|
|
1518
1560
|
if (existing !== undefined && !args.replace) return toolResult(false, { error: `The library already has a skill named ${args.name} (${file}); read it, then pass replace: true to propose a new version, or choose another name` });
|
|
1519
|
-
const rolesFile = path.join(
|
|
1561
|
+
const rolesFile = path.join(library, 'role-skills.json');
|
|
1520
1562
|
const readRoles = async () => {
|
|
1521
1563
|
const raw = await readFile(rolesFile, 'utf8').catch(() => '{}');
|
|
1522
1564
|
const roles = JSON.parse(raw);
|
|
@@ -1524,49 +1566,74 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1524
1566
|
return roles as Record<string, unknown>;
|
|
1525
1567
|
};
|
|
1526
1568
|
try { await readRoles(); } catch (error) { return toolResult(false, { error: errorData(error).message }); }
|
|
1527
|
-
|
|
1528
|
-
|
|
1569
|
+
if (approvalCanceled(session, cancellation)) return toolResult(false, { error: 'Skill proposal canceled or session closed; no approval was created' });
|
|
1570
|
+
if ([...userQuestions.values()].some(pending => pending.question.sessionId === sessionId)) return toolResult(false, { error: 'A question is already waiting for the user; finish it before proposing a skill' });
|
|
1571
|
+
const approvalId = `q-${randomUUID()}`;
|
|
1572
|
+
const record: { approvalId: string; draftKey: string; result?: Record<string, unknown> } = { approvalId, draftKey };
|
|
1573
|
+
approvals.set(key, record);
|
|
1529
1574
|
const say = wordsFor(session);
|
|
1530
|
-
|
|
1575
|
+
// The user sees the whole skill, as it will be saved.
|
|
1576
|
+
const decision = confirm(sessionId, session,
|
|
1531
1577
|
say.skill(session.mapping.agent.name, existing !== undefined, file, args.roles.join(', '), lessons) +
|
|
1532
|
-
`\n${say.answerLine(session.mapping.agent.name)}\n\n${content}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
roles
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1578
|
+
`\n${say.answerLine(session.mapping.agent.name)}\n\n${content}`, { id: approvalId });
|
|
1579
|
+
const save = async (): Promise<Record<string, unknown>> => {
|
|
1580
|
+
// A skill of this name saved meanwhile is not overwritten by a proposal that was not a replacement.
|
|
1581
|
+
if (existing === undefined && !args.replace && await readFile(file, 'utf8').then(() => true, () => false)) {
|
|
1582
|
+
return { approved: true, saved: false, error: `A skill named ${args.name} was saved meanwhile (${file}); nothing was overwritten`, next: 'Read it, then propose again with replace: true if yours should replace it.' };
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
1586
|
+
await writeFile(file, content);
|
|
1587
|
+
const roles = await readRoles();
|
|
1588
|
+
for (const role of args.roles) {
|
|
1589
|
+
const list = Array.isArray(roles[role]) ? roles[role] as string[] : [];
|
|
1590
|
+
if (!list.includes(args.name)) list.push(args.name);
|
|
1591
|
+
roles[role] = list;
|
|
1592
|
+
}
|
|
1593
|
+
await writeFile(rolesFile, JSON.stringify(roles, null, 2) + '\n');
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
return { approved: true, saved: false, error: errorData(error).message, next: 'Tell the user; nothing is retried automatically.' };
|
|
1596
|
+
}
|
|
1597
|
+
// The lessons the skill replaces leave both files; a lesson matches only by its exact text.
|
|
1598
|
+
const removed = new Set<string>();
|
|
1599
|
+
for (const lessonFile of lessonFiles(session.mapping)) {
|
|
1600
|
+
const current = await readFile(lessonFile, 'utf8').catch(() => undefined);
|
|
1601
|
+
if (current === undefined) continue;
|
|
1602
|
+
const kept = current.split('\n').filter(line => {
|
|
1603
|
+
const match = /^- \d{4}-\d\d-\d\d: (.*)$/.exec(line);
|
|
1604
|
+
if (!match || !lessons.includes(match[1].trim())) return true;
|
|
1605
|
+
removed.add(match[1].trim());
|
|
1606
|
+
return false;
|
|
1607
|
+
});
|
|
1608
|
+
if (kept.length !== current.split('\n').length) await writeFile(lessonFile, kept.join('\n')).catch(() => {});
|
|
1609
|
+
}
|
|
1610
|
+
runLog(rootOf(sessionId), { event: 'skill', agent: session.mapping.agent.name, name: args.name, roles: args.roles, replaced: existing !== undefined, lessons: [...removed] });
|
|
1611
|
+
return {
|
|
1612
|
+
approved: true, saved: file, roles: args.roles, lessonsRemoved: [...removed],
|
|
1613
|
+
...(lessons.length > removed.size ? { lessonsNotFound: lessons.filter(lesson => !removed.has(lesson)) } : {}),
|
|
1614
|
+
next: 'New sessions of those roles list the skill; read the file now if you need it in this one.',
|
|
1615
|
+
};
|
|
1616
|
+
};
|
|
1617
|
+
void (async () => {
|
|
1618
|
+
const answer = await decision;
|
|
1619
|
+
const canceled = approvalCanceled(session, cancellation);
|
|
1620
|
+
const result = !answer.approved || canceled
|
|
1621
|
+
? { approved: false, saved: false, outcome: canceled ? 'canceled' : answer.outcome, ...(answer.feedback ? { feedback: answer.feedback } : {}), next: 'Nothing was saved. Revise from the feedback and propose again with a fresh requestId, or drop it.' }
|
|
1622
|
+
: await save();
|
|
1623
|
+
record.result = { status: 'resolved', approvalId, name: args.name, ...result };
|
|
1624
|
+
runLog(rootOf(sessionId), { event: 'skill.approval', agent: session.mapping.agent.name, ...record.result });
|
|
1625
|
+
if (!closed && !session.closed && sessions.get(sessionId) === session) {
|
|
1626
|
+
// Result mail renews the bounded wake allowance and reaches this main only.
|
|
1627
|
+
post(sessionId, { kind: 'result', from: 'ALP', assignment: approvalId, body: `Skill approval result: ${JSON.stringify(record.result)}` });
|
|
1628
|
+
}
|
|
1629
|
+
})();
|
|
1630
|
+
return toolResult(true, { status: 'pending_approval', approvalId, file, next: 'Continue other work or end your turn. ALP sends the user\'s decision, and whether the skill was saved, by mail; do not resubmit this draft.' });
|
|
1566
1631
|
}
|
|
1567
1632
|
|
|
1568
1633
|
async function issueTool(sessionId: string, session: Session, args: unknown) {
|
|
1569
|
-
|
|
1634
|
+
const cancellation = approvalCancellation.get(session) ?? 0;
|
|
1635
|
+
args = structuredClone(args);
|
|
1636
|
+
if (!plainObject(args, ['action', 'target', 'query', 'title', 'body', 'issue', 'labels', 'requestId']) || !['search', 'create', 'comment'].includes(args.action) || !['project', 'alp'].includes(args.target)) {
|
|
1570
1637
|
return toolResult(false, { error: 'An issue call needs action (search, create or comment) and target (project or alp)' });
|
|
1571
1638
|
}
|
|
1572
1639
|
const project = session.mapping.agent.projectRoot;
|
|
@@ -1589,6 +1656,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1589
1656
|
if (args.labels !== undefined && (args.action !== 'create' || !Array.isArray(args.labels) || args.labels.length > 10 || !args.labels.every((label: unknown) => text(label, 50)))) {
|
|
1590
1657
|
return toolResult(false, { error: 'labels are up to 10 existing label names, for create' });
|
|
1591
1658
|
}
|
|
1659
|
+
if (args.requestId !== undefined && !text(args.requestId, 100)) return toolResult(false, { error: 'requestId must be a nonempty string of at most 100 characters' });
|
|
1592
1660
|
// Resolve labels before approval, so a draft with an unknown label never
|
|
1593
1661
|
// consumes the user's approval and then fails during issue creation.
|
|
1594
1662
|
if (args.action === 'create' && args.labels?.length) {
|
|
@@ -1601,24 +1669,48 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1601
1669
|
return toolResult(false, { repo, error: `Could not validate issue labels: ${errorData(error).message}` });
|
|
1602
1670
|
}
|
|
1603
1671
|
}
|
|
1672
|
+
if (approvalCanceled(session, cancellation)) return toolResult(false, { error: 'Issue proposal canceled or session closed; no approval was created' });
|
|
1673
|
+
const draftKey = JSON.stringify([repo, args.action, args.title ?? null, args.issue ?? null, args.body, [...(args.labels ?? [])].sort()]);
|
|
1674
|
+
const key = `issue:${args.requestId ?? draftKey}`;
|
|
1675
|
+
const approvals = approvalsOf(sessionId);
|
|
1676
|
+
const previous = approvals.get(key);
|
|
1677
|
+
if (previous && previous.draftKey !== draftKey) return toolResult(false, { error: 'requestId was already used for a different draft' });
|
|
1678
|
+
if (previous) return toolResult(true, previous.result ?? { status: 'pending_approval', approvalId: previous.approvalId, repo });
|
|
1679
|
+
if ([...userQuestions.values()].some(pending => pending.question.sessionId === sessionId)) return toolResult(false, { error: 'A question is already waiting for the user; finish it before proposing another issue draft' });
|
|
1680
|
+
const approvalId = `q-${randomUUID()}`;
|
|
1681
|
+
const record: { approvalId: string; draftKey: string; result?: Record<string, unknown> } = { approvalId, draftKey };
|
|
1682
|
+
approvals.set(key, record);
|
|
1604
1683
|
const where = `${repo}${args.target === 'alp' ? ' (ALP itself)' : ''}`;
|
|
1605
1684
|
const say = wordsFor(session);
|
|
1606
|
-
const decision =
|
|
1685
|
+
const decision = confirm(sessionId, session,
|
|
1607
1686
|
say.issue(session.mapping.agent.name, args.action === 'create', args.issue, where) + '\n' +
|
|
1608
1687
|
`${say.answerLine(session.mapping.agent.name)}\n\n` +
|
|
1609
|
-
(args.action === 'create' ? `${say.issueTitle}: ${args.title}\n${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(', ')}\n` : ''}\n` : '') + args.body);
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1688
|
+
(args.action === 'create' ? `${say.issueTitle}: ${args.title}\n${args.labels?.length ? `${say.issueLabels}: ${args.labels.join(', ')}\n` : ''}\n` : '') + args.body.trim() + ISSUE_FOOTER, { id: approvalId });
|
|
1689
|
+
void (async () => {
|
|
1690
|
+
const answer = await decision;
|
|
1691
|
+
let result: Record<string, unknown>;
|
|
1692
|
+
const canceled = approvalCanceled(session, cancellation);
|
|
1693
|
+
if (!answer.approved || canceled) {
|
|
1694
|
+
result = { approved: false, posted: false, outcome: canceled ? 'canceled' : answer.outcome, ...(answer.feedback ? { feedback: answer.feedback } : {}) };
|
|
1695
|
+
} else {
|
|
1696
|
+
try {
|
|
1697
|
+
const body = args.body.trim() + ISSUE_FOOTER;
|
|
1698
|
+
const output = args.action === 'create'
|
|
1699
|
+
? await github(['issue', 'create', '-R', repo, '--title', args.title, '--body-file', '-', ...(args.labels ?? []).flatMap((label: string) => ['--label', label])], { cwd: project, input: body })
|
|
1700
|
+
: await github(['issue', 'comment', String(args.issue), '-R', repo, '--body-file', '-'], { cwd: project, input: body });
|
|
1701
|
+
result = { approved: true, posted: true, url: output.trim().split('\n').at(-1) ?? '' };
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
result = { approved: true, posted: false, error: errorData(error).message, next: 'Tell the user; do not retry this draft automatically: GitHub may have accepted it before reporting an error.' };
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
record.result = { status: 'resolved', approvalId, repo, ...result };
|
|
1707
|
+
runLog(rootOf(sessionId), { event: 'issue.approval', action: args.action, ...(args.title ? { title: args.title } : {}), ...(args.issue ? { issue: args.issue } : {}), ...record.result });
|
|
1708
|
+
if (!closed && !session.closed && sessions.get(sessionId) === session) {
|
|
1709
|
+
// Result mail renews the bounded wake allowance and reaches this main only.
|
|
1710
|
+
post(sessionId, { kind: 'result', from: 'ALP', assignment: approvalId, body: `Issue approval result: ${JSON.stringify(record.result)}` });
|
|
1711
|
+
}
|
|
1712
|
+
})();
|
|
1713
|
+
return toolResult(true, { status: 'pending_approval', approvalId, repo, next: 'Continue other work. ALP sends the decision and posting result by mail; do not resubmit this draft.' });
|
|
1622
1714
|
}
|
|
1623
1715
|
|
|
1624
1716
|
/** Activity anywhere in a subtree keeps its ancestors' assignments alive. */
|
|
@@ -1645,6 +1737,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1645
1737
|
if (session && !session.closed) {
|
|
1646
1738
|
emit(sessionId, { type: 'mail', mail: publicEvent(mail) });
|
|
1647
1739
|
session.mail.push(mail);
|
|
1740
|
+
// New assignment progress deserves a fresh delivery allowance. Reset here,
|
|
1741
|
+
// never on redelivery, so a failing turn cannot retry the same mail forever.
|
|
1742
|
+
if (mail.kind === 'result' || mail.kind === 'question') session.wakes = 0;
|
|
1743
|
+
if (mail.kind === 'result' || mail.kind === 'question' || mail.kind === 'checkin') session.wakeFailures = 0;
|
|
1648
1744
|
deliver(sessionId, session);
|
|
1649
1745
|
}
|
|
1650
1746
|
return mail;
|
|
@@ -1673,7 +1769,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1673
1769
|
if (session.mail.some(event => !event.deliveredTurn && !event.passive && !event.defer)) void steerMail(sessionId, session);
|
|
1674
1770
|
} else if (!waking) {
|
|
1675
1771
|
return;
|
|
1676
|
-
} else if (!session.wakeBlocked && session.wakes < MAX_WAKES) {
|
|
1772
|
+
} else if (!session.wakeBlocked && session.wakeFailures < MAX_WAKES && (session.wakes < MAX_WAKES || session.mail.some(event => !event.deliveredTurn && event.kind === 'checkin'))) {
|
|
1677
1773
|
autoWake(sessionId, session);
|
|
1678
1774
|
} else if (session.parent && !session.wakeBlocked && !held(session)) {
|
|
1679
1775
|
// A child never gets the user prompt that resets its wakes; report instead of waiting out the watchdog.
|
|
@@ -1711,6 +1807,9 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1711
1807
|
function autoWake(sessionId: string, session: Session) {
|
|
1712
1808
|
// Its runtime is paused or it is parked: the mail waits for resume.
|
|
1713
1809
|
if (held(session)) { session.wakeHeld = true; return; }
|
|
1810
|
+
// A check-in authorizes delivery of older mail too: the capped first batch
|
|
1811
|
+
// may not reach the check-in itself. Keep that authorization through queuing.
|
|
1812
|
+
const checkIn = session.mail.find(event => !event.deliveredTurn && event.kind === 'checkin');
|
|
1714
1813
|
const batch = takeBatch(session.mail, () => true);
|
|
1715
1814
|
if (!batch.length) return;
|
|
1716
1815
|
for (const event of batch) event.deliveredTurn = STARTING;
|
|
@@ -1724,11 +1823,15 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1724
1823
|
release();
|
|
1725
1824
|
return deliver(sessionId, session);
|
|
1726
1825
|
}
|
|
1826
|
+
// Earlier queued attempts may have consumed the allowance since delivery
|
|
1827
|
+
// reserved this batch. Keep retries bounded at the actual start boundary.
|
|
1828
|
+
if (session.wakeFailures >= MAX_WAKES || (session.wakes >= MAX_WAKES && (!checkIn || !session.mail.includes(checkIn)))) return release();
|
|
1727
1829
|
if (!batch.every(event => event.kind === 'checkin')) session.wakes++;
|
|
1728
1830
|
const id = `alp-wake-${randomUUID()}`;
|
|
1729
1831
|
try {
|
|
1730
1832
|
await startPrompt(sessionId, { clientMessageId: id, delivery: 'auto', content: [{ type: 'text', text: renderMail(batch, session.parentAgent) }] }, 'wake', batch);
|
|
1731
1833
|
} catch {
|
|
1834
|
+
session.wakeFailures++;
|
|
1732
1835
|
release();
|
|
1733
1836
|
deliver(sessionId, session);
|
|
1734
1837
|
}
|
|
@@ -1842,6 +1945,21 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
1842
1945
|
}
|
|
1843
1946
|
}
|
|
1844
1947
|
|
|
1948
|
+
/**
|
|
1949
|
+
* The user wrote in the chat while main's own alp_ask question to them waits (#116): the question
|
|
1950
|
+
* ends now, so the message reaches main. Approvals, permission prompts and the hook-trust
|
|
1951
|
+
* question keep waiting for the user's decision.
|
|
1952
|
+
*/
|
|
1953
|
+
function releaseQuestions(sessionId: string) {
|
|
1954
|
+
for (const { question, settle, asked } of [...userQuestions.values()]) {
|
|
1955
|
+
if (question.sessionId !== sessionId || !asked) continue;
|
|
1956
|
+
settle('canceled', undefined, 'the user wrote instead of answering', toolResult(true, {
|
|
1957
|
+
status: 'user_wrote', userMessage: true, question: question.id,
|
|
1958
|
+
next: `${USER_WROTE} Their message may be the answer to your question; if it is not, ask again later.`,
|
|
1959
|
+
}));
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1845
1963
|
const assignmentSnapshot = (assignment: Assignment, status: string): AssignmentSnapshot => ({
|
|
1846
1964
|
id: assignment.id,
|
|
1847
1965
|
agent: assignment.agent,
|
|
@@ -2107,7 +2225,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2107
2225
|
const pending = [...session.assignments.keys()].some(id => !ids || ids.has(id)) || takeBatch(session.mail, event => event.kind !== 'board' && !event.defer && accept(event)).length > 0;
|
|
2108
2226
|
const events = pending ? await waitFor(sessionId, session, accept, Math.min(args.timeoutMs ?? 300_000, 900_000)) : [];
|
|
2109
2227
|
if (events === 'user') return toolResult(true, { events: [], userMessage: true, running: running(session), next: USER_WROTE });
|
|
2110
|
-
if (!events) return toolResult(false, { error: 'Turn ended' });
|
|
2228
|
+
if (!events) return toolResult(false, session.closed || session.wakeBlocked ? { error: 'Turn ended' } : { error: TURN_ENDED, running: running(session) });
|
|
2111
2229
|
return toolResult(true, { events: events.map(publicEvent), running: running(session) });
|
|
2112
2230
|
}
|
|
2113
2231
|
|
|
@@ -2149,10 +2267,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2149
2267
|
}
|
|
2150
2268
|
|
|
2151
2269
|
/** Asks the user; viewers show the question, and answer() or a timeout resolves it. */
|
|
2152
|
-
function askUser(sessionId: string, session: Session, assignment: Assignment | undefined, body: string, options?: string[]) {
|
|
2270
|
+
function askUser(sessionId: string, session: Session, assignment: Assignment | undefined, body: string, options?: string[], approval?: { id: string }, asked = false) {
|
|
2153
2271
|
const rootId = rootOf(sessionId);
|
|
2154
2272
|
const question: UserQuestion = {
|
|
2155
|
-
id: `q-${randomUUID().slice(0, 8)}`,
|
|
2273
|
+
id: approval?.id ?? `q-${randomUUID().slice(0, 8)}`,
|
|
2156
2274
|
sessionId,
|
|
2157
2275
|
rootId,
|
|
2158
2276
|
agent: session.mapping.agent.name,
|
|
@@ -2180,7 +2298,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2180
2298
|
next: 'Decide, and record the assumption in your handoff or final message; or report that you are blocked.',
|
|
2181
2299
|
})));
|
|
2182
2300
|
};
|
|
2183
|
-
userQuestions.set(question.id, { question, settle });
|
|
2301
|
+
userQuestions.set(question.id, { question, settle, persistent: !!approval, asked });
|
|
2184
2302
|
// The watchdog does not count time spent waiting for an answer.
|
|
2185
2303
|
if (assignment) assignment.ask = { id: question.id, resolve: result => settle('canceled', undefined, undefined, result) };
|
|
2186
2304
|
emit(sessionId, { type: 'question', question });
|
|
@@ -2198,10 +2316,10 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2198
2316
|
post(session.parent, { kind: 'note', from: session.mapping.agent.name, assignment: sessionId, body: `[ALP, on behalf of ${session.mapping.agent.name}] ${body}` });
|
|
2199
2317
|
}
|
|
2200
2318
|
|
|
2201
|
-
/**
|
|
2202
|
-
function cancelQuestions(sessionId: string) {
|
|
2203
|
-
for (const { question, settle } of [...userQuestions.values()]) {
|
|
2204
|
-
if (question.sessionId === sessionId) settle('canceled', undefined, undefined, toolResult(false, { error: 'Turn ended' }));
|
|
2319
|
+
/** Ordinary questions end with their turn; issue approvals survive until decision or session cancellation. */
|
|
2320
|
+
function cancelQuestions(sessionId: string, turnOnly = false) {
|
|
2321
|
+
for (const { question, settle, persistent } of [...userQuestions.values()]) {
|
|
2322
|
+
if (question.sessionId === sessionId && (!turnOnly || !persistent)) settle('canceled', undefined, undefined, toolResult(false, { error: 'Turn ended' }));
|
|
2205
2323
|
}
|
|
2206
2324
|
}
|
|
2207
2325
|
|
|
@@ -2223,7 +2341,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2223
2341
|
if (to === 'user' && session.parent && !session.userOpened) {
|
|
2224
2342
|
return Promise.resolve(toolResult(false, { error: `Only main talks to the user; ask ${session.parentAgent ?? 'your requester'} with alp_ask instead` }));
|
|
2225
2343
|
}
|
|
2226
|
-
if (to === 'user') return askUser(sessionId, session, assignment, args.question, args.options);
|
|
2344
|
+
if (to === 'user') return askUser(sessionId, session, assignment, args.question, args.options, undefined, true);
|
|
2227
2345
|
if (!session.parent || !parent || !assignment) return Promise.resolve(toolResult(false, { error: 'This session has no requester; ask the user with to: "user"' }));
|
|
2228
2346
|
const parentId = session.parent;
|
|
2229
2347
|
return new Promise<unknown>(resolve => {
|
|
@@ -2415,9 +2533,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2415
2533
|
const rootId = rootOf(sessionId);
|
|
2416
2534
|
const root = sessions.get(rootId)!;
|
|
2417
2535
|
|
|
2418
|
-
|
|
2536
|
+
const advisor = ['advisor', 'reviewer'].includes(roleOf(session.mapping, args.agent) ?? '') && childMode === 'read-only';
|
|
2537
|
+
const budget = advisor ? 'advisorCalls' : 'calls';
|
|
2538
|
+
if (root[budget] >= 16) {
|
|
2419
2539
|
return toolResult(false, {
|
|
2420
|
-
error: '
|
|
2540
|
+
error: `${advisor ? 'Advisor' : 'Implementation'} delegation budget exhausted (16 starts across the root session tree since its last new user turn). Mail wakes and child turns do not reset it; a new accepted user prompt in root (including steering) resets both budgets.`,
|
|
2421
2541
|
});
|
|
2422
2542
|
}
|
|
2423
2543
|
|
|
@@ -2432,7 +2552,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2432
2552
|
const continued = args.continueFrom !== undefined ? session.worktrees.get(args.continueFrom) : undefined;
|
|
2433
2553
|
if (args.continueFrom !== undefined && !continued && !sharedContinuation) return toolResult(false, { error: `${args.continueFrom} was merged or discarded meanwhile` });
|
|
2434
2554
|
if (continued) session.worktrees.delete(args.continueFrom);
|
|
2435
|
-
root
|
|
2555
|
+
root[budget]++;
|
|
2436
2556
|
if (peer) root.peerCount++;
|
|
2437
2557
|
|
|
2438
2558
|
const childId = `alp-child-${randomUUID()}`;
|
|
@@ -2598,12 +2718,16 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2598
2718
|
if (events === 'user') return toolResult(true, { assignmentId: childId, agent: args.agent, status: 'running', userMessage: true, next: USER_WROTE });
|
|
2599
2719
|
|
|
2600
2720
|
if (!events) {
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2721
|
+
// Only the user's stop or a closing session ends the assignment with the wait (#116).
|
|
2722
|
+
if (session.closed || session.wakeBlocked || !session.assignments.has(childId)) {
|
|
2723
|
+
return toolResult(false, {
|
|
2724
|
+
agent: args.agent,
|
|
2725
|
+
assignmentId: childId,
|
|
2726
|
+
status: 'canceled',
|
|
2727
|
+
error: 'Parent assignment stopped',
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
return toolResult(false, { agent: args.agent, assignmentId: childId, status: 'running', error: TURN_ENDED });
|
|
2607
2731
|
}
|
|
2608
2732
|
|
|
2609
2733
|
// A result that came with a check-in makes the check-in moot.
|
|
@@ -2702,10 +2826,12 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
2702
2826
|
peerCount: 0,
|
|
2703
2827
|
assignments: new Map(),
|
|
2704
2828
|
calls: 0,
|
|
2829
|
+
advisorCalls: 0,
|
|
2705
2830
|
mail: [],
|
|
2706
2831
|
waiters: [],
|
|
2707
2832
|
lastActivity: Date.now(),
|
|
2708
2833
|
wakes: 0,
|
|
2834
|
+
wakeFailures: 0,
|
|
2709
2835
|
wakeBlocked: false,
|
|
2710
2836
|
|
|
2711
2837
|
toolCalls: new Map(),
|
|
@@ -3054,7 +3180,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3054
3180
|
* root first, since each assignment needs its requester open. Each one continues
|
|
3055
3181
|
* its turn, or waits parked when its runtime is paused or the user resumes recovery.
|
|
3056
3182
|
*/
|
|
3057
|
-
async function recoverTree(rootId: string, continueRoot: boolean): Promise<RecoveryOutcome[]> {
|
|
3183
|
+
async function recoverTree(rootId: string, continueRoot: boolean, parkedRoot?: string): Promise<RecoveryOutcome[]> {
|
|
3058
3184
|
const root = sessions.get(rootId);
|
|
3059
3185
|
if (!root || root.closed) throw new Error(`Session ${rootId} is not open`);
|
|
3060
3186
|
const entries = inherited.filter(entry => entry.rootId === rootId).sort((a, b) => a.ancestry.length - b.ancestry.length);
|
|
@@ -3094,7 +3220,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3094
3220
|
runLog(rootId, { event: 'assignment.recovered', assignmentId: entry.assignmentId, agent: entry.agent, ...(entry.taskId ? { taskId: entry.taskId } : {}) });
|
|
3095
3221
|
proceed(entry.assignmentId, child, `ALP: ${cause} while you worked on this assignment, and reopened this session. Your earlier work in it${entry.worktree ? ' and your worktree' : ''} is intact; check where you stopped (for example with git status), then continue. Finish with alp_handoff as before.${listed(resumed.get(entry.assignmentId))}`);
|
|
3096
3222
|
}
|
|
3097
|
-
if (
|
|
3223
|
+
if (parkedRoot) {
|
|
3224
|
+
root.parked = { reason: parkedRoot, since: Date.now() };
|
|
3225
|
+
emit(rootId, { type: 'session.updated', session: snapshot(rootId, root) });
|
|
3226
|
+
if (options.autoResume !== false && options.recoveryResume !== false && !pauseOf(root.runtimeKind)) continueParked(rootId, root);
|
|
3227
|
+
} else if (continueRoot) proceed(rootId, root, `ALP: ${cause} during your turn, and reopened this session. Continue the user's request where you stopped.${listed(resumed.get(rootId))}`);
|
|
3098
3228
|
else for (const assignment of resumed.get(rootId) ?? []) post(rootId, { kind: 'note', from: assignment.agent, assignment: assignment.id, passive: true, body: `${cause}; ALP reopened this assignment and it continues. It reports when done.` });
|
|
3099
3229
|
const failed = outcomes.filter(outcome => outcome.outcome === 'failed');
|
|
3100
3230
|
if (entries.length || continueRoot) {
|
|
@@ -3154,6 +3284,11 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3154
3284
|
|
|
3155
3285
|
session.wakeBlocked = true;
|
|
3156
3286
|
session.userStopped = true;
|
|
3287
|
+
session.parked = undefined;
|
|
3288
|
+
session.parkReason = undefined;
|
|
3289
|
+
emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
|
|
3290
|
+
approvalCancellation.set(session, (approvalCancellation.get(session) ?? 0) + 1);
|
|
3291
|
+
cancelQuestions(sessionId);
|
|
3157
3292
|
// Codex keeps no output of an interrupted turn in its thread: remember what the user saw, by
|
|
3158
3293
|
// thread, since a client that leaves closes the session and the next prompt opens it again.
|
|
3159
3294
|
if (activeTurn && session.runtimeKind === 'codex' && session.threadId) {
|
|
@@ -3264,12 +3399,9 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3264
3399
|
if (!wake) {
|
|
3265
3400
|
session.handoff = undefined;
|
|
3266
3401
|
session.wakes = 0;
|
|
3402
|
+
session.wakeFailures = 0;
|
|
3267
3403
|
session.wakeBlocked = false;
|
|
3268
3404
|
session.userStopped = false;
|
|
3269
|
-
|
|
3270
|
-
if (!session.parent) {
|
|
3271
|
-
session.calls = 0;
|
|
3272
|
-
}
|
|
3273
3405
|
}
|
|
3274
3406
|
}
|
|
3275
3407
|
|
|
@@ -3335,6 +3467,12 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3335
3467
|
|
|
3336
3468
|
for (const event of mail) event.deliveredTurn = turnId;
|
|
3337
3469
|
|
|
3470
|
+
// Only transport-accepted user input renews the root-tree budgets.
|
|
3471
|
+
if (!session.parent && origin === 'user') {
|
|
3472
|
+
session.calls = 0;
|
|
3473
|
+
session.advisorCalls = 0;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3338
3476
|
session.seen.add(
|
|
3339
3477
|
prompt.clientMessageId,
|
|
3340
3478
|
);
|
|
@@ -3352,7 +3490,7 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3352
3490
|
if (origin === 'user' && !session.parent) session.userWaiting ??= Date.now();
|
|
3353
3491
|
if (session.supervisor && origin === 'user') session.journal.push(stamp(`user ${prompt.delivery === 'steer' ? 'steered' : 'asked'} ${session.mapping.agent.name}: ${clip(text, 1500)}`));
|
|
3354
3492
|
// The steer reaches the model once its waiting tool returns; return it now, not when the work ends.
|
|
3355
|
-
if (prompt.delivery === 'steer' && origin === 'user' && !session.parent) releaseWaiters(session);
|
|
3493
|
+
if (prompt.delivery === 'steer' && origin === 'user' && !session.parent) { releaseWaiters(session); releaseQuestions(sessionId); }
|
|
3356
3494
|
|
|
3357
3495
|
emit(sessionId, {
|
|
3358
3496
|
type: 'prompt.accepted',
|
|
@@ -3411,8 +3549,16 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3411
3549
|
}),
|
|
3412
3550
|
|
|
3413
3551
|
prompt: (sessionId, input) => enqueue(async () => {
|
|
3552
|
+
const session = sessions.get(sessionId);
|
|
3553
|
+
const parked = session?.parked;
|
|
3414
3554
|
try {
|
|
3415
|
-
|
|
3555
|
+
const prompt = session?.active && session.resumedTurn === session.active && input.delivery === 'auto'
|
|
3556
|
+
? { ...input, delivery: 'steer' as const } : input;
|
|
3557
|
+
await startPrompt(sessionId, prompt, 'user');
|
|
3558
|
+
if (session && parked && session.parked === parked) {
|
|
3559
|
+
session.parked = undefined;
|
|
3560
|
+
emit(sessionId, { type: 'session.updated', session: snapshot(sessionId, session) });
|
|
3561
|
+
}
|
|
3416
3562
|
} catch (error) {
|
|
3417
3563
|
const session = sessions.get(sessionId);
|
|
3418
3564
|
if (session?.seen.has(input.clientMessageId)) return;
|
|
@@ -3578,8 +3724,8 @@ export function createAlpRuntime(options: RuntimeOptions = {}): AlpRuntime {
|
|
|
3578
3724
|
return [...inherited];
|
|
3579
3725
|
},
|
|
3580
3726
|
|
|
3581
|
-
recover(rootId, { continueRoot = false } = {}) {
|
|
3582
|
-
return enqueue(() => recoverTree(rootId, continueRoot));
|
|
3727
|
+
recover(rootId, { continueRoot = false, parkedRoot } = {}) {
|
|
3728
|
+
return enqueue(() => recoverTree(rootId, continueRoot, parkedRoot));
|
|
3583
3729
|
},
|
|
3584
3730
|
|
|
3585
3731
|
async abandon(rootId, reason) {
|