-
Notifications
You must be signed in to change notification settings - Fork 3
feat(design): auto-retry crashed persona once before giving up #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { gateway } from './config.js' | ||
| import { registry } from './sessions.js' | ||
| import { doSpawnSession, killSession, killsInProgress } from './session-lifecycle.js' | ||
| import { doSpawnSession, killSession, killsInProgress, waitForBridge, HEALTH_TIMEOUT_MS } from './session-lifecycle.js' | ||
| import { transport } from './bridge-transport.js' | ||
| import { getReviewByThread } from './adversarial.js' | ||
| import { getBuildByThread } from './build.js' | ||
|
|
@@ -59,6 +59,9 @@ export type DesignState = { | |
| refinementExpected: number | ||
| refinementResponses: number | ||
| refinementRespondedIds: Set<string> | ||
| activeDivergence?: { description: string; personas: string[]; impact: string } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: This inline type
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (bump):
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (bump):
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (bump): This inline type is the same shape as divergence objects in |
||
| retriedPersonas: Set<string> | ||
| retryingPersonas: Set<string> | ||
| timeout?: ReturnType<typeof setTimeout> | ||
| _synthesizerDisconnectTimer?: ReturnType<typeof setTimeout> | ||
| _auditorDisconnectTimer?: ReturnType<typeof setTimeout> | ||
|
|
@@ -154,6 +157,8 @@ export async function startDesign( | |
| refinementExpected: 0, | ||
| refinementResponses: 0, | ||
| refinementRespondedIds: new Set(), | ||
| retriedPersonas: new Set(), | ||
| retryingPersonas: new Set(), | ||
| } | ||
|
|
||
| designs.set(threadId, state) | ||
|
|
@@ -324,7 +329,7 @@ async function cleanupDesignSessions(state: DesignState, reason: string): Promis | |
| // Cancel a design | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export async function cancelDesign(threadId: string): Promise<void> { | ||
| export async function cancelDesign(threadId: string, message?: string): Promise<void> { | ||
| const state = designs.get(threadId) | ||
| if (!state) return | ||
|
|
||
|
|
@@ -337,7 +342,7 @@ export async function cancelDesign(threadId: string): Promise<void> { | |
| await cleanupDesignSessions(state, 'design cancelled') | ||
| designs.delete(threadId) | ||
| refreshSessionVisual(threadId) | ||
| await gateway.send(state.ownerThreadId, `Design session cancelled.`) | ||
| await gateway.send(state.ownerThreadId, message ?? `Design session cancelled.`) | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -565,12 +570,13 @@ async function runRefinement( | |
|
|
||
| async function processNextDivergence(state: DesignState): Promise<void> { | ||
| if (!state.refinementQueue || state.refinementQueue.length === 0) { | ||
| // All divergences refined — auto re-synthesize | ||
| state.activeDivergence = undefined | ||
| void autoAdvanceAfterRefinement(state) | ||
| return | ||
| } | ||
|
|
||
| const divergence = state.refinementQueue.shift()! | ||
| state.activeDivergence = divergence | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (flagged by both reviewers): Fix: set |
||
| state.currentDivergence++ | ||
| state.refinementResponses = 0 | ||
| state.refinementRespondedIds = new Set() | ||
|
|
@@ -615,6 +621,81 @@ async function processNextDivergence(state: DesignState): Promise<void> { | |
| }, PERSONA_TIMEOUT_MS) | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Auto-retry a crashed persona once | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| async function retryPersona( | ||
| state: DesignState, | ||
| deadPersona: { name: string; sessionId: string; proposed: boolean }, | ||
| threadId: string, | ||
| ): Promise<void> { | ||
| const name = deadPersona.name as PersonaName | ||
| const cutoffTs = new Date().toISOString() | ||
| const oldSessionId = deadPersona.sessionId | ||
|
|
||
| if (state.phase === 'cancelled' || state.phase === 'complete') return | ||
| state.retryingPersonas.add(name) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (new, flagged by ts-reviewer): No early-exit guard for Fix: add |
||
|
|
||
| try { | ||
| const oldInfo = registry.get(oldSessionId) | ||
| if (oldInfo && !killsInProgress.has(oldSessionId)) { | ||
| await killSession(oldInfo, 'persona crashed, retrying').catch(() => {}) | ||
| } | ||
|
|
||
| const result = await doSpawnSession(`Design persona: ${name}`, undefined, undefined, { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (new, flagged by both reviewers): The old persona session is never killed before spawning the replacement. The bridge disconnected (that's how we got here), but the tmux session and registry entry for the old sessionId may linger. This leaks resources. Add explicit cleanup before spawning: const oldInfo = registry.get(deadPersona.sessionId)
if (oldInfo && !killsInProgress.has(deadPersona.sessionId)) {
await killSession(oldInfo, 'persona crashed, retrying').catch(() => {})
} |
||
| joinThread: threadId, | ||
| memberLabel: name, | ||
| promptBuilder: (sessionId, tmuxName) => designPersonaPrompt({ sessionId, tmuxName, persona: name, topic: state.topic, threadId, cutoffTs }), | ||
| }) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump, flagged by both reviewers): Fix: Defer mutation until after health check: const newSessionId = result.sessionId
const ok = await waitForBridge(newSessionId, HEALTH_TIMEOUT_MS)
if (!ok) { /* kill newSessionId, throw */ }
deadPersona.sessionId = newSessionId // only now |
||
|
|
||
| deadPersona.sessionId = result.sessionId | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix: in-place sessionId mutation loses the old session ID. Two consequences:
Consider keeping the old ID (e.g., a
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (flagged by both reviewers): Fix: move this assignment after the
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump): Fix: move this assignment after the
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump, flagged by both reviewers): Additionally, the old session is never explicitly killed before spawning the replacement. Compare with the synthesizer timeout handler which kills the old session before respawning. Fix: save the old session ID, kill the old session before spawning, and only assign the new session ID after the health check succeeds.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump, flagged by both reviewers): Acknowledged: old-session cleanup (line 638-641) and Fix: capture |
||
|
|
||
| const ok = await waitForBridge(result.sessionId, HEALTH_TIMEOUT_MS) | ||
| if (!ok) { | ||
| const info = registry.get(result.sessionId) | ||
| if (info) await killSession(info, 'persona retry health check failed').catch(() => {}) | ||
| throw new Error('bridge did not connect') | ||
| } | ||
|
|
||
| if (state.phase === 'cancelled' || state.phase === 'complete') { | ||
| const info = registry.get(result.sessionId) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (new): If the design ends during retry (phase becomes |
||
| if (info) await killSession(info, 'design ended during retry').catch(() => {}) | ||
| return | ||
| } | ||
|
|
||
| process.stderr.write(`daemon: design: ${name} auto-retried as ${result.name}\n`) | ||
| void gateway.send(threadId, `_${name} respawned as **${result.name}**._`).catch(() => {}) | ||
|
|
||
| if (state.phase === 'independent') { | ||
| transport.sendOrQueue(result.sessionId, { | ||
| type: 'notification', | ||
| content: `[system] You are replacing a crashed persona. Read the thread for context, then post your proposal. Tag with \`[${name}→thread]\`. Be INDEPENDENT.`, | ||
| meta: { chat_id: threadId, message_id: '', user: 'system', user_id: 'system', ts: cutoffTs }, | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (new, flagged by both reviewers): When a retried persona re-enters refinement, Same issue in the Fix: Re-increment |
||
| }) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (new, flagged by both reviewers): |
||
| } else if (state.phase === 'refinement' && state.activeDivergence) { | ||
| if (state.activeDivergence.personas.some(n => n === name || n === name.replaceAll('_', ' ')) && !state.refinementRespondedIds.has(oldSessionId)) { | ||
| transport.sendOrQueue(result.sessionId, { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (new, flagged by both reviewers): |
||
| type: 'notification', | ||
| content: [ | ||
| `[system] Refinement requested on divergence: **${state.activeDivergence.description}**`, | ||
| ``, | ||
| `Critique the synthesized composite design from your lens (${name}).`, | ||
| `Post your response with: \`[${name}→thread]\``, | ||
| ].join('\n'), | ||
| meta: { chat_id: threadId, message_id: '', user: 'system', user_id: 'system', ts: cutoffTs }, | ||
| }) | ||
| } | ||
| } | ||
| } catch (err) { | ||
| process.stderr.write(`daemon: design: ${name} auto-retry failed: ${err}\n`) | ||
| state.retryingPersonas.delete(name) | ||
| onDesignParticipantDisconnect(deadPersona.sessionId) | ||
| return | ||
| } | ||
| state.retryingPersonas.delete(name) | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Participant disconnect / reconnect — grace timer before cancelling | ||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -637,14 +718,32 @@ export function onDesignParticipantDisconnect(sessionId: string): void { | |
| : isAuditor ? 'auditor' | ||
| : 'brief writer' | ||
|
|
||
| // Personas are expendable — adjust expectations immediately (no grace timer) | ||
| // Personas are expendable — try auto-retry once, then adjust expectations | ||
| if (persona) { | ||
| process.stderr.write(`daemon: design: ${label} disconnected/died\n`) | ||
| void gateway.send(threadId, `_${label} disconnected. Continuing with ${state.personas.filter(p => p.sessionId !== sessionId).length} remaining personas._`).catch(() => {}) | ||
| if (state.retryingPersonas.has(persona.name)) return | ||
|
|
||
| const aliveCount = state.personas.filter(p => p.sessionId !== sessionId && transport.has(p.sessionId)).length | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix: If two personas crash near-simultaneously and both retries are exhausted, each computes This is a narrow race but possible under load. A mitigation: recheck
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Mitigation: recheck alive count just before the cancel decision.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Consider recomputing
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Additionally, Fix: Recompute alive count at the cancel decision point, and exclude |
||
| process.stderr.write(`daemon: design: ${label} disconnected/died (${aliveCount} alive)\n`) | ||
|
|
||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Fix: recompute |
||
| if (!state.retriedPersonas.has(persona.name)) { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (new, flagged by both reviewers): Add |
||
| state.retriedPersonas.add(persona.name) | ||
| void gateway.send(threadId, `_${label} crashed — auto-retrying..._`).catch(() => {}) | ||
| void retryPersona(state, persona, threadId) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix: Also, if Consider: (1) capture the phase at crash time and use it for prompt selection, (2) check phase before spawn in addition to after.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Consider: (1) capture the phase at crash time for prompt selection, (2) check phase before spawn in addition to after.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump, flagged by both reviewers): Consider capturing the phase at retry start and comparing when the retry completes, or adding a phase-change check before sending the prompt.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): |
||
| return | ||
| } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump, flagged by both reviewers): Fix: capture |
||
|
|
||
| void gateway.send(threadId, `_${label} disconnected (retry exhausted). ${aliveCount > 0 ? `Continuing with ${aliveCount} remaining persona${aliveCount !== 1 ? 's' : ''}.` : 'All personas dead.'}_`).catch(() => {}) | ||
|
|
||
| if (aliveCount === 0) { | ||
| if (state.timeout) { clearTimeout(state.timeout); state.timeout = undefined } | ||
| process.stderr.write(`daemon: design: all personas dead — cancelling\n`) | ||
| void cancelDesign(threadId, `All personas crashed. Design cancelled.\nUse \`design: ${state.topic}\` to retry.`).catch(e => process.stderr.write(`daemon: design: cancel failed: ${e}\n`)) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (new, flagged by both reviewers): Fix: set state.phase = 'cancelled' as any // prevent re-entry
void cancelDesign(threadId, ...).catch(...)
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Consider setting
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): |
||
| return | ||
| } | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (bump): Fix: set |
||
|
|
||
| if (state.phase === 'questioning') { | ||
| state.questionsExpected-- | ||
| if (state.questionsExpected > 0 && state.questionsReceived >= state.questionsExpected) { | ||
| if (state.questionsReceived >= state.questionsExpected) { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should-fix (flagged by both reviewers): removing the The old code had Suggestion: restore the
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (flagged by both reviewers): Removing the Scenario: persona A is alive and working, persona B (retry exhausted) disconnects. Fix: restore a
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump): Removing the This is reachable when combined with the counter-corruption race from the
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump, flagged by both reviewers): Removing the Scenario: 2 personas alive, both crash in the same phase. First crash triggers retry (returns early). Second crash hits the exhausted path, decrements The
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump, flagged by both reviewers): Removing the The Fix: Either restore the |
||
| if (state.timeout) clearTimeout(state.timeout) | ||
| const result = designMachine.transition(state.phase, 'all_questions') | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker (bump, flagged by both reviewers): Removing the Scenario: 2 personas, both crash in questioning phase. First crash triggers retry (returns early, no decrement). Second crash: retry exhausted → Fix: restore |
||
| if (result.ok) { | ||
|
|
@@ -654,7 +753,7 @@ export function onDesignParticipantDisconnect(sessionId: string): void { | |
| } | ||
| } else if (state.phase === 'independent' && !persona.proposed) { | ||
| state.proposalsExpected-- | ||
| if (state.proposalsExpected > 0 && state.proposalsReceived >= state.proposalsExpected) { | ||
| if (state.proposalsReceived >= state.proposalsExpected) { | ||
| if (state.timeout) clearTimeout(state.timeout) | ||
| const result = designMachine.transition(state.phase, 'all_proposed') | ||
| if (result.ok) { | ||
|
|
@@ -665,7 +764,7 @@ export function onDesignParticipantDisconnect(sessionId: string): void { | |
| } | ||
| } else if (state.phase === 'refinement') { | ||
| state.refinementExpected-- | ||
| if (state.refinementExpected > 0 && state.refinementResponses >= state.refinementExpected) { | ||
| if (state.refinementResponses >= state.refinementExpected) { | ||
| if (state.timeout) clearTimeout(state.timeout) | ||
| void processNextDivergence(state) | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit (bump):
activeDivergenceinline type{ description: string; personas: string[]; impact: string }is presumably the same shape as divergence objects inrefinementQueue. Extract a sharedDivergencetype to keep them in sync.