@exaudeus/workrail 3.70.7 → 3.71.1

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.
@@ -59,11 +59,16 @@ exports.makeSignalCoordinatorTool = makeSignalCoordinatorTool;
59
59
  exports.buildSessionRecap = buildSessionRecap;
60
60
  exports.buildSystemPrompt = buildSystemPrompt;
61
61
  exports.tagToStatsOutcome = tagToStatsOutcome;
62
+ exports.sidecardLifecycleFor = sidecardLifecycleFor;
62
63
  exports.buildAgentClient = buildAgentClient;
63
64
  exports.createSessionState = createSessionState;
64
65
  exports.evaluateStuckSignals = evaluateStuckSignals;
65
66
  exports.finalizeSession = finalizeSession;
66
67
  exports.buildSessionContext = buildSessionContext;
68
+ exports.buildPreAgentSession = buildPreAgentSession;
69
+ exports.buildTurnEndSubscriber = buildTurnEndSubscriber;
70
+ exports.buildAgentCallbacks = buildAgentCallbacks;
71
+ exports.buildSessionResult = buildSessionResult;
67
72
  exports.runWorkflow = runWorkflow;
68
73
  require("reflect-metadata");
69
74
  const fs = __importStar(require("node:fs/promises"));
@@ -82,6 +87,7 @@ const v2_token_ops_js_1 = require("../mcp/handlers/v2-token-ops.js");
82
87
  const index_js_2 = require("../v2/durable-core/ids/index.js");
83
88
  const node_outputs_js_1 = require("../v2/projections/node-outputs.js");
84
89
  const assert_never_js_1 = require("../runtime/assert-never.js");
90
+ const result_js_1 = require("../runtime/result.js");
85
91
  const session_recovery_policy_js_1 = require("./session-recovery-policy.js");
86
92
  const stats_summary_js_1 = require("./stats-summary.js");
87
93
  const execAsync = (0, node_util_1.promisify)(node_child_process_1.exec);
@@ -122,22 +128,29 @@ var soul_template_js_2 = require("./soul-template.js");
122
128
  Object.defineProperty(exports, "DAEMON_SOUL_DEFAULT", { enumerable: true, get: function () { return soul_template_js_2.DAEMON_SOUL_DEFAULT; } });
123
129
  Object.defineProperty(exports, "DAEMON_SOUL_TEMPLATE", { enumerable: true, get: function () { return soul_template_js_2.DAEMON_SOUL_TEMPLATE; } });
124
130
  async function persistTokens(sessionId, continueToken, checkpointToken, worktreePath, recoveryContext) {
125
- await fs.mkdir(exports.DAEMON_SESSIONS_DIR, { recursive: true });
126
- const sessionPath = path.join(exports.DAEMON_SESSIONS_DIR, `${sessionId}.json`);
127
- const state = JSON.stringify({
128
- continueToken,
129
- checkpointToken,
130
- ts: Date.now(),
131
- ...(worktreePath !== undefined ? { worktreePath } : {}),
132
- ...(recoveryContext !== undefined ? {
133
- workflowId: recoveryContext.workflowId,
134
- goal: recoveryContext.goal,
135
- workspacePath: recoveryContext.workspacePath,
136
- } : {}),
137
- }, null, 2);
138
- const tmp = `${sessionPath}.tmp`;
139
- await fs.writeFile(tmp, state, 'utf8');
140
- await fs.rename(tmp, sessionPath);
131
+ try {
132
+ await fs.mkdir(exports.DAEMON_SESSIONS_DIR, { recursive: true });
133
+ const sessionPath = path.join(exports.DAEMON_SESSIONS_DIR, `${sessionId}.json`);
134
+ const state = JSON.stringify({
135
+ continueToken,
136
+ checkpointToken,
137
+ ts: Date.now(),
138
+ ...(worktreePath !== undefined ? { worktreePath } : {}),
139
+ ...(recoveryContext !== undefined ? {
140
+ workflowId: recoveryContext.workflowId,
141
+ goal: recoveryContext.goal,
142
+ workspacePath: recoveryContext.workspacePath,
143
+ } : {}),
144
+ }, null, 2);
145
+ const tmp = `${sessionPath}.tmp`;
146
+ await fs.writeFile(tmp, state, 'utf8');
147
+ await fs.rename(tmp, sessionPath);
148
+ return (0, result_js_1.ok)(undefined);
149
+ }
150
+ catch (e) {
151
+ const nodeErr = e;
152
+ return (0, result_js_1.err)({ code: nodeErr.code ?? 'UNKNOWN', message: nodeErr.message ?? String(e) });
153
+ }
141
154
  }
142
155
  async function appendConversationMessages(filePath, messages) {
143
156
  if (messages.length === 0)
@@ -199,7 +212,7 @@ async function readAllDaemonSessions(sessionsDir = exports.DAEMON_SESSIONS_DIR)
199
212
  }
200
213
  return sessions;
201
214
  }
202
- async function runStartupRecovery(sessionsDir = exports.DAEMON_SESSIONS_DIR, execFn = execFileAsync, ctx, _countStepAdvancesFn = countOrphanStepAdvances, _executeContinueWorkflowFn = index_js_1.executeContinueWorkflow, _runWorkflowFn = runWorkflow) {
215
+ async function runStartupRecovery(sessionsDir = exports.DAEMON_SESSIONS_DIR, execFn = execFileAsync, ctx, _countStepAdvancesFn = countOrphanStepAdvances, _executeContinueWorkflowFn = index_js_1.executeContinueWorkflow, _runWorkflowFn = runWorkflow, apiKey = '') {
203
216
  await clearQueueIssueSidecars(sessionsDir);
204
217
  const sessions = await readAllDaemonSessions(sessionsDir);
205
218
  if (sessions.length === 0) {
@@ -308,7 +321,7 @@ async function runStartupRecovery(sessionsDir = exports.DAEMON_SESSIONS_DIR, exe
308
321
  };
309
322
  console.log(`[WorkflowRunner] Startup recovery: resuming session ${session.sessionId} ` +
310
323
  `workflowId=${session.workflowId} stepAdvances=${stepAdvances}`);
311
- void _runWorkflowFn(recoveredTrigger, ctx, process.env['ANTHROPIC_API_KEY'] ?? '').then((result) => {
324
+ void _runWorkflowFn(recoveredTrigger, ctx, apiKey).then((result) => {
312
325
  console.log(`[WorkflowRunner] Startup recovery: resumed session ${session.sessionId} completed: ${result._tag}`);
313
326
  }).catch((err) => {
314
327
  console.warn(`[WorkflowRunner] Startup recovery: resumed session ${session.sessionId} failed: ` +
@@ -728,7 +741,10 @@ function makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas
728
741
  const checkpointToken = out.checkpointToken ?? null;
729
742
  const persistToken = (out.kind === 'blocked' ? out.nextCall?.params.continueToken : undefined) ?? continueToken;
730
743
  if (persistToken) {
731
- await persistTokens(sessionId, persistToken, checkpointToken);
744
+ const persistResult = await persistTokens(sessionId, persistToken, checkpointToken);
745
+ if (persistResult.kind === 'err') {
746
+ console.warn(`[WorkflowRunner] persistTokens failed (continue_workflow): ${persistResult.error.code} -- ${persistResult.error.message}`);
747
+ }
732
748
  }
733
749
  if (out.kind === 'blocked') {
734
750
  const retryToken = out.nextCall?.params.continueToken ?? continueToken;
@@ -829,7 +845,10 @@ function makeCompleteStepTool(sessionId, ctx, getCurrentToken, onAdvance, onComp
829
845
  const checkpointToken = out.checkpointToken ?? null;
830
846
  const persistToken = (out.kind === 'blocked' ? out.nextCall?.params.continueToken : undefined) ?? newContinueToken;
831
847
  if (persistToken) {
832
- await persistTokens(sessionId, persistToken, checkpointToken);
848
+ const persistResult = await persistTokens(sessionId, persistToken, checkpointToken);
849
+ if (persistResult.kind === 'err') {
850
+ console.warn(`[WorkflowRunner] persistTokens failed (complete_step): ${persistResult.error.code} -- ${persistResult.error.message}`);
851
+ }
833
852
  }
834
853
  if (out.kind === 'blocked') {
835
854
  const retryToken = out.nextCall?.params.continueToken ?? newContinueToken;
@@ -903,6 +922,8 @@ function makeBashTool(workspacePath, schemas, sessionId, emitter, workrailSessio
903
922
  inputSchema: schemas['BashParams'],
904
923
  label: 'Bash',
905
924
  execute: async (_toolCallId, params) => {
925
+ if (typeof params.command !== 'string' || !params.command)
926
+ throw new Error('Bash: command must be a non-empty string');
906
927
  console.log(`[WorkflowRunner] Tool: bash "${String(params.command).slice(0, 80)}"`);
907
928
  if (sessionId)
908
929
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Bash', summary: String(params.command).slice(0, 80), ...withWorkrailSession(workrailSessionId) });
@@ -964,6 +985,8 @@ function makeReadTool(readFileState, schemas, sessionId, emitter, workrailSessio
964
985
  inputSchema: schemas['ReadParams'],
965
986
  label: 'Read',
966
987
  execute: async (_toolCallId, params) => {
988
+ if (typeof params.filePath !== 'string' || !params.filePath)
989
+ throw new Error('Read: filePath must be a non-empty string');
967
990
  const filePath = params.filePath;
968
991
  if (sessionId)
969
992
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Read', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
@@ -1002,6 +1025,10 @@ function makeWriteTool(readFileState, schemas, sessionId, emitter, workrailSessi
1002
1025
  inputSchema: schemas['WriteParams'],
1003
1026
  label: 'Write',
1004
1027
  execute: async (_toolCallId, params) => {
1028
+ if (typeof params.filePath !== 'string' || !params.filePath)
1029
+ throw new Error('Write: filePath must be a non-empty string');
1030
+ if (typeof params.content !== 'string')
1031
+ throw new Error('Write: content must be a string');
1005
1032
  const filePath = params.filePath;
1006
1033
  if (sessionId)
1007
1034
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Write', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
@@ -1042,6 +1069,8 @@ function makeGlobTool(workspacePath, schemas, sessionId, emitter, workrailSessio
1042
1069
  inputSchema: schemas['GlobParams'],
1043
1070
  label: 'Glob',
1044
1071
  execute: async (_toolCallId, params) => {
1072
+ if (typeof params.pattern !== 'string' || !params.pattern)
1073
+ throw new Error('Glob: pattern must be a non-empty string');
1045
1074
  const pattern = params.pattern;
1046
1075
  const searchRoot = params.path ?? workspacePath;
1047
1076
  if (sessionId)
@@ -1091,6 +1120,8 @@ function makeGrepTool(workspacePath, schemas, sessionId, emitter, workrailSessio
1091
1120
  inputSchema: schemas['GrepParams'],
1092
1121
  label: 'Grep',
1093
1122
  execute: async (_toolCallId, params) => {
1123
+ if (typeof params.pattern !== 'string' || !params.pattern)
1124
+ throw new Error('Grep: pattern must be a non-empty string');
1094
1125
  const pattern = params.pattern;
1095
1126
  const searchPath = params.path ?? workspacePath;
1096
1127
  const outputMode = params.output_mode ?? 'files_with_matches';
@@ -1167,6 +1198,12 @@ function makeEditTool(workspacePath, readFileState, schemas, sessionId, emitter,
1167
1198
  inputSchema: schemas['EditParams'],
1168
1199
  label: 'Edit',
1169
1200
  execute: async (_toolCallId, params) => {
1201
+ if (typeof params.file_path !== 'string' || !params.file_path)
1202
+ throw new Error('Edit: file_path must be a non-empty string');
1203
+ if (typeof params.old_string !== 'string')
1204
+ throw new Error('Edit: old_string must be a string');
1205
+ if (typeof params.new_string !== 'string')
1206
+ throw new Error('Edit: new_string must be a string');
1170
1207
  const rawFilePath = params.file_path;
1171
1208
  const absoluteFilePath = path.isAbsolute(rawFilePath)
1172
1209
  ? rawFilePath
@@ -1236,6 +1273,12 @@ function makeSpawnAgentTool(sessionId, ctx, apiKey, thisWorkrailSessionId, curre
1236
1273
  inputSchema: schemas['SpawnAgentParams'],
1237
1274
  label: 'Spawn Agent',
1238
1275
  execute: async (_toolCallId, params) => {
1276
+ if (typeof params.workflowId !== 'string' || !params.workflowId)
1277
+ throw new Error('spawn_agent: workflowId must be a non-empty string');
1278
+ if (typeof params.goal !== 'string' || !params.goal)
1279
+ throw new Error('spawn_agent: goal must be a non-empty string');
1280
+ if (typeof params.workspacePath !== 'string' || !params.workspacePath)
1281
+ throw new Error('spawn_agent: workspacePath must be a non-empty string');
1239
1282
  console.log(`[WorkflowRunner] Tool: spawn_agent sessionId=${sessionId} workflowId=${String(params.workflowId)} depth=${currentDepth}/${maxDepth}`);
1240
1283
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'spawn_agent', summary: `${String(params.workflowId)} depth=${currentDepth}`, ...withWorkrailSession(thisWorkrailSessionId) });
1241
1284
  if (currentDepth >= maxDepth) {
@@ -1415,6 +1458,12 @@ function makeReportIssueTool(sessionId, emitter, workrailSessionId, issuesDirOve
1415
1458
  },
1416
1459
  label: 'report_issue',
1417
1460
  execute: async (_toolCallId, params) => {
1461
+ if (typeof params.kind !== 'string' || !params.kind)
1462
+ throw new Error('report_issue: kind must be a non-empty string');
1463
+ if (typeof params.severity !== 'string' || !params.severity)
1464
+ throw new Error('report_issue: severity must be a non-empty string');
1465
+ if (typeof params.summary !== 'string' || !params.summary)
1466
+ throw new Error('report_issue: summary must be a non-empty string');
1418
1467
  const record = {
1419
1468
  sessionId,
1420
1469
  kind: params.kind,
@@ -1486,6 +1535,8 @@ function makeSignalCoordinatorTool(sessionId, emitter, workrailSessionId, signal
1486
1535
  },
1487
1536
  label: 'signal_coordinator',
1488
1537
  execute: async (_toolCallId, params) => {
1538
+ if (typeof params.signalKind !== 'string' || !params.signalKind)
1539
+ throw new Error('signal_coordinator: signalKind must be a non-empty string');
1489
1540
  const signalId = 'sig_' + (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 8);
1490
1541
  const signalKind = String(params.signalKind ?? 'progress');
1491
1542
  const payload = (typeof params.payload === 'object' && params.payload !== null && !Array.isArray(params.payload))
@@ -1629,6 +1680,22 @@ function tagToStatsOutcome(tag) {
1629
1680
  default: return (0, assert_never_js_1.assertNever)(tag);
1630
1681
  }
1631
1682
  }
1683
+ function sidecardLifecycleFor(tag, branchStrategy) {
1684
+ switch (tag) {
1685
+ case 'success':
1686
+ return branchStrategy === 'worktree'
1687
+ ? { kind: 'retain_for_delivery' }
1688
+ : { kind: 'delete_now' };
1689
+ case 'error':
1690
+ case 'timeout':
1691
+ case 'stuck':
1692
+ return { kind: 'delete_now' };
1693
+ case 'delivery_failed':
1694
+ throw new Error(`sidecardLifecycleFor: delivery_failed is not a valid input (invariant 1.2)`);
1695
+ default:
1696
+ return (0, assert_never_js_1.assertNever)(tag);
1697
+ }
1698
+ }
1632
1699
  function buildAgentClient(trigger, apiKey, env) {
1633
1700
  if (trigger.agentConfig?.model) {
1634
1701
  const slashIdx = trigger.agentConfig.model.indexOf('/');
@@ -1726,9 +1793,15 @@ async function finalizeSession(result, ctx) {
1726
1793
  ctx.daemonRegistry?.unregister(ctx.workrailSessionId, result._tag === 'success' || result._tag === 'delivery_failed' ? 'completed' : 'failed');
1727
1794
  }
1728
1795
  writeExecutionStats(ctx.statsDir, ctx.sessionId, ctx.workflowId, ctx.startMs, outcome, ctx.stepAdvanceCount);
1729
- const isWorktreeSuccess = result._tag === 'success' && ctx.branchStrategy === 'worktree';
1730
- if (!isWorktreeSuccess) {
1731
- await fs.unlink(path.join(ctx.sessionsDir, `${ctx.sessionId}.json`)).catch(() => { });
1796
+ const lifecycle = sidecardLifecycleFor(result._tag, ctx.branchStrategy);
1797
+ switch (lifecycle.kind) {
1798
+ case 'delete_now':
1799
+ await fs.unlink(path.join(ctx.sessionsDir, `${ctx.sessionId}.json`)).catch(() => { });
1800
+ break;
1801
+ case 'retain_for_delivery':
1802
+ break;
1803
+ default:
1804
+ (0, assert_never_js_1.assertNever)(lifecycle);
1732
1805
  }
1733
1806
  if (result._tag === 'success' && ctx.branchStrategy !== 'worktree') {
1734
1807
  await fs.unlink(ctx.conversationPath).catch(() => { });
@@ -1747,18 +1820,7 @@ function buildSessionContext(trigger, inputs) {
1747
1820
  const maxTurns = trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS;
1748
1821
  return { systemPrompt, initialPrompt, sessionTimeoutMs, maxTurns };
1749
1822
  }
1750
- async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerRegistry, abortRegistry, _statsDir, _sessionsDir) {
1751
- const statsDir = _statsDir ?? DAEMON_STATS_DIR;
1752
- const sessionsDir = _sessionsDir ?? exports.DAEMON_SESSIONS_DIR;
1753
- const startMs = Date.now();
1754
- const sessionId = (0, node_crypto_1.randomUUID)();
1755
- console.log(`[WorkflowRunner] Session started: sessionId=${sessionId} workflowId=${trigger.workflowId}`);
1756
- emitter?.emit({
1757
- kind: 'session_started',
1758
- sessionId,
1759
- workflowId: trigger.workflowId,
1760
- workspacePath: trigger.workspacePath,
1761
- });
1823
+ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, statsDir, sessionsDir, emitter, daemonRegistry, steerRegistry) {
1762
1824
  let agentClient;
1763
1825
  let modelId;
1764
1826
  try {
@@ -1776,32 +1838,12 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1776
1838
  }
1777
1839
  }
1778
1840
  }
1779
- catch (err) {
1780
- const message = err instanceof Error ? err.message : String(err);
1841
+ catch (e) {
1842
+ const message = e instanceof Error ? e.message : String(e);
1781
1843
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1782
- return {
1783
- _tag: 'error',
1784
- workflowId: trigger.workflowId,
1785
- message,
1786
- stopReason: 'error',
1787
- };
1844
+ return { kind: 'complete', result: { _tag: 'error', workflowId: trigger.workflowId, message, stopReason: 'error' } };
1788
1845
  }
1789
1846
  const state = createSessionState('');
1790
- const MAX_ISSUE_SUMMARIES = 10;
1791
- const STUCK_REPEAT_THRESHOLD = 3;
1792
- const onAdvance = (stepText, continueToken) => {
1793
- state.pendingSteerParts.push(stepText);
1794
- state.stepAdvanceCount++;
1795
- state.currentContinueToken = continueToken;
1796
- if (state.workrailSessionId !== null)
1797
- daemonRegistry?.heartbeat(state.workrailSessionId);
1798
- emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(state.workrailSessionId) });
1799
- };
1800
- const onComplete = (notes, artifacts) => {
1801
- state.isComplete = true;
1802
- state.lastStepNotes = notes;
1803
- state.lastStepArtifacts = artifacts;
1804
- };
1805
1847
  let firstStep;
1806
1848
  if (trigger._preAllocatedStartResponse !== undefined) {
1807
1849
  firstStep = trigger._preAllocatedStartResponse;
@@ -1811,41 +1853,47 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1811
1853
  if (startResult.isErr()) {
1812
1854
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1813
1855
  return {
1814
- _tag: 'error',
1815
- workflowId: trigger.workflowId,
1816
- message: `start_workflow failed: ${startResult.error.kind} -- ${JSON.stringify(startResult.error)}`,
1817
- stopReason: 'error',
1856
+ kind: 'complete',
1857
+ result: {
1858
+ _tag: 'error',
1859
+ workflowId: trigger.workflowId,
1860
+ message: `start_workflow failed: ${startResult.error.kind} -- ${JSON.stringify(startResult.error)}`,
1861
+ stopReason: 'error',
1862
+ },
1818
1863
  };
1819
1864
  }
1820
1865
  firstStep = startResult.value.response;
1821
1866
  }
1822
- const startContinueToken = firstStep.continueToken ?? '';
1823
- const startCheckpointToken = firstStep.checkpointToken ?? null;
1824
- state.currentContinueToken = startContinueToken;
1825
- if (startContinueToken) {
1826
- const decoded = await (0, v2_token_ops_js_1.parseContinueTokenOrFail)(startContinueToken, ctx.v2.tokenCodecPorts, ctx.v2.tokenAliasStore);
1867
+ const continueToken = firstStep.continueToken ?? '';
1868
+ const checkpointToken = firstStep.checkpointToken ?? null;
1869
+ state.currentContinueToken = continueToken;
1870
+ if (continueToken) {
1871
+ const decoded = await (0, v2_token_ops_js_1.parseContinueTokenOrFail)(continueToken, ctx.v2.tokenCodecPorts, ctx.v2.tokenAliasStore);
1827
1872
  if (decoded.isOk()) {
1828
1873
  state.workrailSessionId = decoded.value.sessionId;
1829
1874
  }
1830
1875
  else {
1831
- console.error(`[WorkflowRunner] Error: could not decode WorkRail session ID from continueToken -- isLive and liveActivity will not work for this session. Reason: ${decoded.error.message}`);
1876
+ console.error(`[WorkflowRunner] Error: could not decode WorkRail session ID from continueToken -- isLive and liveActivity will not work. Reason: ${decoded.error.message}`);
1832
1877
  }
1833
1878
  }
1834
- if (state.workrailSessionId !== null) {
1835
- daemonRegistry?.register(state.workrailSessionId, trigger.workflowId);
1836
- }
1837
- if (state.workrailSessionId !== null) {
1838
- steerRegistry?.set(state.workrailSessionId, (text) => { state.pendingSteerParts.push(text); });
1839
- }
1840
- if (state.workrailSessionId !== null) {
1841
- abortRegistry?.set(state.workrailSessionId, () => { agent.abort(); });
1842
- }
1843
- if (startContinueToken) {
1844
- await persistTokens(sessionId, startContinueToken, startCheckpointToken, undefined, {
1879
+ if (continueToken) {
1880
+ const persistResult = await persistTokens(sessionId, continueToken, checkpointToken, undefined, {
1845
1881
  workflowId: trigger.workflowId,
1846
1882
  goal: trigger.goal,
1847
1883
  workspacePath: trigger.workspacePath,
1848
1884
  });
1885
+ if (persistResult.kind === 'err') {
1886
+ writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1887
+ return {
1888
+ kind: 'complete',
1889
+ result: {
1890
+ _tag: 'error',
1891
+ workflowId: trigger.workflowId,
1892
+ message: `Initial token persist failed: ${persistResult.error.code} -- ${persistResult.error.message}`,
1893
+ stopReason: 'error',
1894
+ },
1895
+ };
1896
+ }
1849
1897
  }
1850
1898
  let sessionWorkspacePath = trigger.workspacePath;
1851
1899
  let sessionWorktreePath;
@@ -1864,113 +1912,174 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1864
1912
  '-b', `${branchPrefix}${sessionId}`,
1865
1913
  `origin/${baseBranch}`,
1866
1914
  ]);
1867
- await persistTokens(sessionId, startContinueToken ?? state.currentContinueToken, startCheckpointToken, sessionWorktreePath, {
1868
- workflowId: trigger.workflowId,
1869
- goal: trigger.goal,
1870
- workspacePath: trigger.workspacePath,
1871
- });
1872
- console.log(`[WorkflowRunner] Worktree created: sessionId=${sessionId} ` +
1873
- `branch=${branchPrefix}${sessionId} path=${sessionWorkspacePath}`);
1915
+ const worktreePersistResult = await persistTokens(sessionId, continueToken ?? state.currentContinueToken, checkpointToken, sessionWorktreePath, { workflowId: trigger.workflowId, goal: trigger.goal, workspacePath: trigger.workspacePath });
1916
+ if (worktreePersistResult.kind === 'err') {
1917
+ console.error(`[WorkflowRunner] Worktree sidecar persist failed: ${worktreePersistResult.error.code} -- ${worktreePersistResult.error.message}`);
1918
+ try {
1919
+ await execFileAsync('git', ['-C', trigger.workspacePath, 'worktree', 'remove', '--force', sessionWorkspacePath]);
1920
+ }
1921
+ catch { }
1922
+ writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1923
+ return {
1924
+ kind: 'complete',
1925
+ result: {
1926
+ _tag: 'error',
1927
+ workflowId: trigger.workflowId,
1928
+ message: `Worktree sidecar persist failed: ${worktreePersistResult.error.code} -- ${worktreePersistResult.error.message}`,
1929
+ stopReason: 'error',
1930
+ },
1931
+ };
1932
+ }
1933
+ console.log(`[WorkflowRunner] Worktree created: sessionId=${sessionId} branch=${branchPrefix}${sessionId} path=${sessionWorkspacePath}`);
1874
1934
  }
1875
- catch (err) {
1876
- const errMsg = err instanceof Error ? err.message : String(err);
1935
+ catch (e) {
1936
+ const errMsg = e instanceof Error ? e.message : String(e);
1877
1937
  console.error(`[WorkflowRunner] Worktree creation failed: sessionId=${sessionId} error=${errMsg}`);
1878
- emitter?.emit({ kind: 'session_completed', sessionId, workflowId: trigger.workflowId, outcome: 'error', detail: errMsg.slice(0, 200), ...withWorkrailSession(state.workrailSessionId) });
1879
- if (state.workrailSessionId !== null)
1880
- daemonRegistry?.unregister(state.workrailSessionId, 'failed');
1881
- if (state.workrailSessionId !== null) {
1882
- steerRegistry?.delete(state.workrailSessionId);
1883
- abortRegistry?.delete(state.workrailSessionId);
1884
- }
1885
1938
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1886
1939
  return {
1887
- _tag: 'error',
1888
- workflowId: trigger.workflowId,
1889
- message: `Worktree creation failed: ${errMsg}`,
1890
- stopReason: 'error',
1940
+ kind: 'complete',
1941
+ result: { _tag: 'error', workflowId: trigger.workflowId, message: `Worktree creation failed: ${errMsg}`, stopReason: 'error' },
1891
1942
  };
1892
1943
  }
1893
1944
  }
1945
+ if (state.workrailSessionId !== null) {
1946
+ daemonRegistry?.register(state.workrailSessionId, trigger.workflowId);
1947
+ steerRegistry?.set(state.workrailSessionId, (text) => { state.pendingSteerParts.push(text); });
1948
+ }
1894
1949
  if (firstStep.isComplete) {
1895
- if (trigger.branchStrategy !== 'worktree') {
1950
+ const lifecycle = sidecardLifecycleFor('success', trigger.branchStrategy);
1951
+ if (lifecycle.kind === 'delete_now') {
1896
1952
  await fs.unlink(path.join(sessionsDir, `${sessionId}.json`)).catch(() => { });
1897
1953
  }
1898
1954
  emitter?.emit({ kind: 'session_completed', sessionId, workflowId: trigger.workflowId, outcome: 'success', detail: 'stop', ...withWorkrailSession(state.workrailSessionId) });
1899
- if (state.workrailSessionId !== null)
1900
- daemonRegistry?.unregister(state.workrailSessionId, 'completed');
1901
1955
  if (state.workrailSessionId !== null) {
1956
+ daemonRegistry?.unregister(state.workrailSessionId, 'completed');
1902
1957
  steerRegistry?.delete(state.workrailSessionId);
1903
- abortRegistry?.delete(state.workrailSessionId);
1904
1958
  }
1905
1959
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'success', 0);
1906
1960
  return {
1907
- _tag: 'success',
1908
- workflowId: trigger.workflowId,
1909
- stopReason: 'stop',
1910
- ...(sessionWorktreePath !== undefined ? { sessionWorkspacePath: sessionWorktreePath } : {}),
1911
- ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
1912
- ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
1961
+ kind: 'complete',
1962
+ result: {
1963
+ _tag: 'success',
1964
+ workflowId: trigger.workflowId,
1965
+ stopReason: 'stop',
1966
+ ...(sessionWorktreePath !== undefined ? { sessionWorkspacePath: sessionWorktreePath } : {}),
1967
+ ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
1968
+ ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
1969
+ },
1913
1970
  };
1914
1971
  }
1915
- const schemas = getSchemas();
1916
- const spawnCurrentDepth = trigger.spawnDepth ?? 0;
1917
- const spawnMaxDepth = trigger.agentConfig?.maxSubagentDepth ?? 3;
1918
- const readFileState = new Map();
1919
- const tools = [
1920
- makeCompleteStepTool(sessionId, ctx, () => state.currentContinueToken, onAdvance, onComplete, (t) => { state.currentContinueToken = t; }, schemas, index_js_1.executeContinueWorkflow, emitter, state.workrailSessionId),
1921
- makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas, index_js_1.executeContinueWorkflow, emitter, state.workrailSessionId),
1922
- makeBashTool(sessionWorkspacePath, schemas, sessionId, emitter, state.workrailSessionId),
1923
- makeReadTool(readFileState, schemas, sessionId, emitter, state.workrailSessionId),
1924
- makeWriteTool(readFileState, schemas, sessionId, emitter, state.workrailSessionId),
1925
- makeGlobTool(sessionWorkspacePath, schemas, sessionId, emitter, state.workrailSessionId),
1926
- makeGrepTool(sessionWorkspacePath, schemas, sessionId, emitter, state.workrailSessionId),
1927
- makeEditTool(sessionWorkspacePath, readFileState, schemas, sessionId, emitter, state.workrailSessionId),
1928
- makeReportIssueTool(sessionId, emitter, state.workrailSessionId, undefined, (summary) => {
1929
- if (state.issueSummaries.length < MAX_ISSUE_SUMMARIES) {
1972
+ return {
1973
+ kind: 'ready',
1974
+ session: {
1975
+ sessionId,
1976
+ workrailSessionId: state.workrailSessionId,
1977
+ continueToken,
1978
+ checkpointToken,
1979
+ sessionWorkspacePath,
1980
+ sessionWorktreePath,
1981
+ firstStep,
1982
+ state,
1983
+ spawnCurrentDepth: trigger.spawnDepth ?? 0,
1984
+ spawnMaxDepth: trigger.agentConfig?.maxSubagentDepth ?? 3,
1985
+ readFileState: new Map(),
1986
+ agentClient,
1987
+ modelId,
1988
+ startMs,
1989
+ },
1990
+ };
1991
+ }
1992
+ function constructTools(session, ctx, apiKey, schemas, emitter, abortRegistry, onAdvance, onComplete, maxIssueSummaries) {
1993
+ const { state, sessionId: sid, sessionWorkspacePath, readFileState, spawnCurrentDepth, spawnMaxDepth } = session;
1994
+ const workrailSid = state.workrailSessionId;
1995
+ return [
1996
+ makeCompleteStepTool(sid, ctx, () => state.currentContinueToken, onAdvance, onComplete, (t) => { state.currentContinueToken = t; }, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
1997
+ makeContinueWorkflowTool(sid, ctx, onAdvance, onComplete, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
1998
+ makeBashTool(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
1999
+ makeReadTool(readFileState, schemas, sid, emitter, workrailSid),
2000
+ makeWriteTool(readFileState, schemas, sid, emitter, workrailSid),
2001
+ makeGlobTool(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
2002
+ makeGrepTool(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
2003
+ makeEditTool(sessionWorkspacePath, readFileState, schemas, sid, emitter, workrailSid),
2004
+ makeReportIssueTool(sid, emitter, workrailSid, undefined, (summary) => {
2005
+ if (state.issueSummaries.length < maxIssueSummaries) {
1930
2006
  state.issueSummaries.push(summary);
1931
2007
  }
1932
2008
  }),
1933
- makeSpawnAgentTool(sessionId, ctx, apiKey, state.workrailSessionId ?? '', spawnCurrentDepth, spawnMaxDepth, runWorkflow, schemas, emitter, abortRegistry),
1934
- makeSignalCoordinatorTool(sessionId, emitter, state.workrailSessionId),
2009
+ makeSpawnAgentTool(sid, ctx, apiKey, workrailSid ?? '', spawnCurrentDepth, spawnMaxDepth, runWorkflow, schemas, emitter, abortRegistry),
2010
+ makeSignalCoordinatorTool(sid, emitter, workrailSid),
1935
2011
  ];
1936
- const [soulContent, workspaceContext, sessionNotes] = await Promise.all([
1937
- loadDaemonSoul(trigger.soulFile),
1938
- loadWorkspaceContext(trigger.workspacePath),
1939
- startContinueToken ? loadSessionNotes(startContinueToken, ctx) : Promise.resolve([]),
1940
- ]);
1941
- const sessionCtx = buildSessionContext(trigger, {
1942
- soulContent,
1943
- workspaceContext,
1944
- sessionNotes,
1945
- firstStepPrompt: firstStep.pending?.prompt ?? 'No step content available',
1946
- });
1947
- const agentCallbacks = {
2012
+ }
2013
+ function buildTurnEndSubscriber(ctx) {
2014
+ return async (event) => {
2015
+ if (event.type !== 'turn_end')
2016
+ return;
2017
+ for (const toolResult of event.toolResults) {
2018
+ if (toolResult.isError) {
2019
+ const errorText = toolResult.result?.content[0]?.text ?? 'tool error';
2020
+ ctx.emitter?.emit({ kind: 'tool_error', sessionId: ctx.sessionId, toolName: toolResult.toolName, error: errorText.slice(0, 200), ...withWorkrailSession(ctx.state.workrailSessionId) });
2021
+ }
2022
+ }
2023
+ ctx.state.turnCount++;
2024
+ const signal = evaluateStuckSignals(ctx.state, ctx.stuckConfig);
2025
+ if (signal !== null) {
2026
+ if (signal.kind === 'max_turns_exceeded') {
2027
+ ctx.state.timeoutReason = 'max_turns';
2028
+ ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'timeout_imminent', detail: 'Max-turn limit reached', ...withWorkrailSession(ctx.state.workrailSessionId) });
2029
+ ctx.agent.abort();
2030
+ return;
2031
+ }
2032
+ else if (signal.kind === 'repeated_tool_call') {
2033
+ ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'repeated_tool_call', detail: `Same tool+args called ${ctx.stuckRepeatThreshold} times: ${signal.toolName}`, toolName: signal.toolName, argsSummary: signal.argsSummary, ...withWorkrailSession(ctx.state.workrailSessionId) });
2034
+ void writeStuckOutboxEntry({ workflowId: ctx.workflowId, reason: 'repeated_tool_call', ...(ctx.state.issueSummaries.length > 0 ? { issueSummaries: [...ctx.state.issueSummaries] } : {}) });
2035
+ if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only' && ctx.state.stuckReason === null && ctx.state.timeoutReason === null) {
2036
+ ctx.state.stuckReason = 'repeated_tool_call';
2037
+ ctx.agent.abort();
2038
+ return;
2039
+ }
2040
+ }
2041
+ else if (signal.kind === 'no_progress') {
2042
+ ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'no_progress', detail: `${signal.turnCount} turns used, 0 step advances (${signal.maxTurns} turn limit)`, ...withWorkrailSession(ctx.state.workrailSessionId) });
2043
+ if (ctx.stuckConfig.noProgressAbortEnabled) {
2044
+ void writeStuckOutboxEntry({ workflowId: ctx.workflowId, reason: 'no_progress', ...(ctx.state.issueSummaries.length > 0 ? { issueSummaries: [...ctx.state.issueSummaries] } : {}) });
2045
+ if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only' && ctx.state.stuckReason === null && ctx.state.timeoutReason === null) {
2046
+ ctx.state.stuckReason = 'no_progress';
2047
+ ctx.agent.abort();
2048
+ return;
2049
+ }
2050
+ }
2051
+ }
2052
+ else if (signal.kind === 'timeout_imminent') {
2053
+ ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'timeout_imminent', detail: `${signal.timeoutReason === 'wall_clock' ? 'Wall-clock timeout' : 'Max-turn limit'} reached`, ...withWorkrailSession(ctx.state.workrailSessionId) });
2054
+ }
2055
+ else {
2056
+ (0, assert_never_js_1.assertNever)(signal);
2057
+ }
2058
+ }
2059
+ const currentMessages = ctx.agent.state.messages;
2060
+ const newMessages = currentMessages.slice(ctx.lastFlushedRef.count);
2061
+ ctx.lastFlushedRef.count = currentMessages.length;
2062
+ void appendConversationMessages(ctx.conversationPath, newMessages).catch(() => { });
2063
+ if (ctx.state.pendingSteerParts.length > 0 && !ctx.state.isComplete) {
2064
+ const joined = ctx.state.pendingSteerParts.join('\n\n');
2065
+ ctx.state.pendingSteerParts.length = 0;
2066
+ ctx.agent.steer(buildUserMessage(joined));
2067
+ }
2068
+ };
2069
+ }
2070
+ function buildAgentCallbacks(sessionId, state, modelId, emitter, stuckRepeatThreshold) {
2071
+ return {
1948
2072
  onLlmTurnStarted: ({ messageCount }) => {
1949
- emitter?.emit({
1950
- kind: 'llm_turn_started',
1951
- sessionId,
1952
- messageCount,
1953
- modelId,
1954
- ...withWorkrailSession(state.workrailSessionId),
1955
- });
2073
+ emitter?.emit({ kind: 'llm_turn_started', sessionId, messageCount, modelId, ...withWorkrailSession(state.workrailSessionId) });
1956
2074
  },
1957
2075
  onLlmTurnCompleted: ({ stopReason, outputTokens, inputTokens, toolNamesRequested }) => {
1958
- emitter?.emit({
1959
- kind: 'llm_turn_completed',
1960
- sessionId,
1961
- stopReason,
1962
- outputTokens,
1963
- inputTokens,
1964
- toolNamesRequested,
1965
- ...withWorkrailSession(state.workrailSessionId),
1966
- });
2076
+ emitter?.emit({ kind: 'llm_turn_completed', sessionId, stopReason, outputTokens, inputTokens, toolNamesRequested, ...withWorkrailSession(state.workrailSessionId) });
1967
2077
  },
1968
2078
  onToolCallStarted: ({ toolName, argsSummary }) => {
1969
2079
  emitter?.emit({ kind: 'tool_call_started', sessionId, toolName, argsSummary, ...withWorkrailSession(state.workrailSessionId) });
1970
2080
  state.lastNToolCalls.push({ toolName, argsSummary });
1971
- if (state.lastNToolCalls.length > STUCK_REPEAT_THRESHOLD) {
2081
+ if (state.lastNToolCalls.length > stuckRepeatThreshold)
1972
2082
  state.lastNToolCalls.shift();
1973
- }
1974
2083
  },
1975
2084
  onToolCallCompleted: ({ toolName, durationMs, resultSummary }) => {
1976
2085
  emitter?.emit({ kind: 'tool_call_completed', sessionId, toolName, durationMs, resultSummary, ...withWorkrailSession(state.workrailSessionId) });
@@ -1979,6 +2088,114 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1979
2088
  emitter?.emit({ kind: 'tool_call_failed', sessionId, toolName, durationMs, errorMessage, ...withWorkrailSession(state.workrailSessionId) });
1980
2089
  },
1981
2090
  };
2091
+ }
2092
+ function buildSessionResult(state, stopReason, errorMessage, trigger, sessionId, sessionWorktreePath) {
2093
+ if (state.stuckReason !== null) {
2094
+ return {
2095
+ _tag: 'stuck',
2096
+ workflowId: trigger.workflowId,
2097
+ reason: state.stuckReason,
2098
+ message: `Session aborted: stuck heuristic fired (${state.stuckReason})`,
2099
+ stopReason: 'aborted',
2100
+ ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
2101
+ };
2102
+ }
2103
+ if (state.timeoutReason !== null) {
2104
+ const limitDescription = state.timeoutReason === 'wall_clock'
2105
+ ? `${trigger.agentConfig?.maxSessionMinutes ?? exports.DEFAULT_SESSION_TIMEOUT_MINUTES} minutes`
2106
+ : `${trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS} turns`;
2107
+ return {
2108
+ _tag: 'timeout',
2109
+ workflowId: trigger.workflowId,
2110
+ reason: state.timeoutReason,
2111
+ message: `Workflow ${state.timeoutReason === 'wall_clock' ? 'timed out' : 'exceeded turn limit'} after ${limitDescription}`,
2112
+ stopReason: 'aborted',
2113
+ };
2114
+ }
2115
+ if (stopReason === 'error' || errorMessage) {
2116
+ const errMsg = errorMessage ?? 'Agent stopped with error reason';
2117
+ const lastToolCalled = state.lastNToolCalls.length > 0 ? state.lastNToolCalls[state.lastNToolCalls.length - 1] : null;
2118
+ const stuckMarker = `\n\nWORKTRAIN_STUCK: ${JSON.stringify({
2119
+ reason: 'session_error',
2120
+ error: errMsg.slice(0, 500),
2121
+ workflowId: trigger.workflowId,
2122
+ sessionId,
2123
+ turnCount: state.turnCount,
2124
+ stepAdvanceCount: state.stepAdvanceCount,
2125
+ ...(lastToolCalled !== null && { lastToolCalled }),
2126
+ ...(state.issueSummaries.length > 0 && { issueSummaries: state.issueSummaries }),
2127
+ })}`;
2128
+ return {
2129
+ _tag: 'error',
2130
+ workflowId: trigger.workflowId,
2131
+ message: errMsg,
2132
+ stopReason,
2133
+ lastStepNotes: stuckMarker,
2134
+ };
2135
+ }
2136
+ return {
2137
+ _tag: 'success',
2138
+ workflowId: trigger.workflowId,
2139
+ stopReason,
2140
+ ...(state.lastStepNotes !== undefined ? { lastStepNotes: state.lastStepNotes } : {}),
2141
+ ...(state.lastStepArtifacts !== undefined ? { lastStepArtifacts: state.lastStepArtifacts } : {}),
2142
+ ...(sessionWorktreePath !== undefined ? { sessionWorkspacePath: sessionWorktreePath } : {}),
2143
+ ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
2144
+ ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
2145
+ };
2146
+ }
2147
+ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerRegistry, abortRegistry, _statsDir, _sessionsDir) {
2148
+ const statsDir = _statsDir ?? DAEMON_STATS_DIR;
2149
+ const sessionsDir = _sessionsDir ?? exports.DAEMON_SESSIONS_DIR;
2150
+ const startMs = Date.now();
2151
+ const sessionId = (0, node_crypto_1.randomUUID)();
2152
+ console.log(`[WorkflowRunner] Session started: sessionId=${sessionId} workflowId=${trigger.workflowId}`);
2153
+ emitter?.emit({
2154
+ kind: 'session_started',
2155
+ sessionId,
2156
+ workflowId: trigger.workflowId,
2157
+ workspacePath: trigger.workspacePath,
2158
+ });
2159
+ const preResult = await buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, statsDir, sessionsDir, emitter, daemonRegistry, steerRegistry);
2160
+ if (preResult.kind === 'complete') {
2161
+ return preResult.result;
2162
+ }
2163
+ const session = preResult.session;
2164
+ const { state, firstStep, sessionWorkspacePath, sessionWorktreePath, agentClient, modelId } = session;
2165
+ const startContinueToken = session.continueToken;
2166
+ const MAX_ISSUE_SUMMARIES = 10;
2167
+ const STUCK_REPEAT_THRESHOLD = 3;
2168
+ const onAdvance = (stepText, continueToken) => {
2169
+ state.pendingSteerParts.push(stepText);
2170
+ state.stepAdvanceCount++;
2171
+ state.currentContinueToken = continueToken;
2172
+ if (state.workrailSessionId !== null)
2173
+ daemonRegistry?.heartbeat(state.workrailSessionId);
2174
+ emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(state.workrailSessionId) });
2175
+ };
2176
+ const onComplete = (notes, artifacts) => {
2177
+ state.isComplete = true;
2178
+ state.lastStepNotes = notes;
2179
+ state.lastStepArtifacts = artifacts;
2180
+ state.stepAdvanceCount++;
2181
+ if (state.workrailSessionId !== null)
2182
+ daemonRegistry?.heartbeat(state.workrailSessionId);
2183
+ emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(state.workrailSessionId) });
2184
+ };
2185
+ const schemas = getSchemas();
2186
+ const tools = constructTools(session, ctx, apiKey, schemas, emitter, abortRegistry, onAdvance, onComplete, MAX_ISSUE_SUMMARIES);
2187
+ const [soulContent, workspaceContext, sessionNotes] = await Promise.all([
2188
+ loadDaemonSoul(trigger.soulFile),
2189
+ loadWorkspaceContext(trigger.workspacePath),
2190
+ startContinueToken ? loadSessionNotes(startContinueToken, ctx) : Promise.resolve([]),
2191
+ ]);
2192
+ const sessionCtx = buildSessionContext(trigger, {
2193
+ soulContent,
2194
+ workspaceContext,
2195
+ sessionNotes,
2196
+ firstStepPrompt: firstStep.pending?.prompt ?? 'No step content available',
2197
+ });
2198
+ const agentCallbacks = buildAgentCallbacks(sessionId, state, modelId, emitter, STUCK_REPEAT_THRESHOLD);
1982
2199
  const agent = new agent_loop_js_1.AgentLoop({
1983
2200
  systemPrompt: sessionCtx.systemPrompt,
1984
2201
  modelId,
@@ -1990,6 +2207,9 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1990
2207
  ? { maxTokens: trigger.agentConfig.maxOutputTokens }
1991
2208
  : {}),
1992
2209
  });
2210
+ if (state.workrailSessionId !== null) {
2211
+ abortRegistry?.set(state.workrailSessionId, () => { agent.abort(); });
2212
+ }
1993
2213
  const { sessionTimeoutMs, maxTurns } = sessionCtx;
1994
2214
  const stuckConfig = {
1995
2215
  maxTurns,
@@ -1998,96 +2218,18 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1998
2218
  stuckRepeatThreshold: STUCK_REPEAT_THRESHOLD,
1999
2219
  };
2000
2220
  const conversationPath = path.join(sessionsDir, `${sessionId}-conversation.jsonl`);
2001
- let lastFlushedMessageCount = 0;
2002
- const unsubscribe = agent.subscribe(async (event) => {
2003
- if (event.type !== 'turn_end')
2004
- return;
2005
- for (const toolResult of event.toolResults) {
2006
- if (toolResult.isError) {
2007
- const errorText = toolResult.result?.content[0]?.text ?? 'tool error';
2008
- emitter?.emit({ kind: 'tool_error', sessionId, toolName: toolResult.toolName, error: errorText.slice(0, 200), ...withWorkrailSession(state.workrailSessionId) });
2009
- }
2010
- }
2011
- state.turnCount++;
2012
- const signal = evaluateStuckSignals(state, stuckConfig);
2013
- if (signal !== null) {
2014
- if (signal.kind === 'max_turns_exceeded') {
2015
- state.timeoutReason = 'max_turns';
2016
- emitter?.emit({
2017
- kind: 'agent_stuck',
2018
- sessionId,
2019
- reason: 'timeout_imminent',
2020
- detail: 'Max-turn limit reached',
2021
- ...withWorkrailSession(state.workrailSessionId),
2022
- });
2023
- agent.abort();
2024
- return;
2025
- }
2026
- else if (signal.kind === 'repeated_tool_call') {
2027
- emitter?.emit({
2028
- kind: 'agent_stuck',
2029
- sessionId,
2030
- reason: 'repeated_tool_call',
2031
- detail: `Same tool+args called ${STUCK_REPEAT_THRESHOLD} times: ${signal.toolName}`,
2032
- toolName: signal.toolName,
2033
- argsSummary: signal.argsSummary,
2034
- ...withWorkrailSession(state.workrailSessionId),
2035
- });
2036
- void writeStuckOutboxEntry({
2037
- workflowId: trigger.workflowId,
2038
- reason: 'repeated_tool_call',
2039
- ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
2040
- });
2041
- if (stuckConfig.stuckAbortPolicy !== 'notify_only' && state.stuckReason === null && state.timeoutReason === null) {
2042
- state.stuckReason = 'repeated_tool_call';
2043
- agent.abort();
2044
- return;
2045
- }
2046
- }
2047
- else if (signal.kind === 'no_progress') {
2048
- emitter?.emit({
2049
- kind: 'agent_stuck',
2050
- sessionId,
2051
- reason: 'no_progress',
2052
- detail: `${signal.turnCount} turns used, 0 step advances (${signal.maxTurns} turn limit)`,
2053
- ...withWorkrailSession(state.workrailSessionId),
2054
- });
2055
- if (stuckConfig.noProgressAbortEnabled) {
2056
- void writeStuckOutboxEntry({
2057
- workflowId: trigger.workflowId,
2058
- reason: 'no_progress',
2059
- ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
2060
- });
2061
- if (stuckConfig.stuckAbortPolicy !== 'notify_only' && state.stuckReason === null && state.timeoutReason === null) {
2062
- state.stuckReason = 'no_progress';
2063
- agent.abort();
2064
- return;
2065
- }
2066
- }
2067
- }
2068
- else if (signal.kind === 'timeout_imminent') {
2069
- emitter?.emit({
2070
- kind: 'agent_stuck',
2071
- sessionId,
2072
- reason: 'timeout_imminent',
2073
- detail: `${signal.timeoutReason === 'wall_clock' ? 'Wall-clock timeout' : 'Max-turn limit'} reached`,
2074
- ...withWorkrailSession(state.workrailSessionId),
2075
- });
2076
- }
2077
- else {
2078
- (0, assert_never_js_1.assertNever)(signal);
2079
- }
2080
- }
2081
- const currentMessages = agent.state.messages;
2082
- const newMessages = currentMessages.slice(lastFlushedMessageCount);
2083
- lastFlushedMessageCount = currentMessages.length;
2084
- void appendConversationMessages(conversationPath, newMessages).catch(() => { });
2085
- if (state.pendingSteerParts.length > 0 && !state.isComplete) {
2086
- const joined = state.pendingSteerParts.join('\n\n');
2087
- state.pendingSteerParts.length = 0;
2088
- agent.steer(buildUserMessage(joined));
2089
- }
2090
- });
2221
+ const lastFlushedRef = { count: 0 };
2222
+ const unsubscribe = agent.subscribe(buildTurnEndSubscriber({
2223
+ agent,
2224
+ state,
2225
+ stuckConfig,
2226
+ sessionId,
2227
+ workflowId: trigger.workflowId,
2228
+ emitter,
2229
+ conversationPath,
2230
+ lastFlushedRef,
2231
+ stuckRepeatThreshold: STUCK_REPEAT_THRESHOLD,
2232
+ }));
2091
2233
  let stopReason = 'stop';
2092
2234
  let errorMessage;
2093
2235
  let timeoutHandle;
@@ -2124,7 +2266,7 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
2124
2266
  }
2125
2267
  finally {
2126
2268
  unsubscribe();
2127
- const remainingMessages = agent.state.messages.slice(lastFlushedMessageCount);
2269
+ const remainingMessages = agent.state.messages.slice(lastFlushedRef.count);
2128
2270
  void appendConversationMessages(conversationPath, remainingMessages).catch(() => { });
2129
2271
  if (timeoutHandle !== undefined)
2130
2272
  clearTimeout(timeoutHandle);
@@ -2149,65 +2291,7 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
2149
2291
  daemonRegistry,
2150
2292
  workflowId: trigger.workflowId,
2151
2293
  };
2152
- if (state.stuckReason !== null) {
2153
- const result = {
2154
- _tag: 'stuck',
2155
- workflowId: trigger.workflowId,
2156
- reason: state.stuckReason,
2157
- message: `Session aborted: stuck heuristic fired (${state.stuckReason})`,
2158
- stopReason: 'aborted',
2159
- ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
2160
- };
2161
- await finalizeSession(result, finalizationCtx);
2162
- return result;
2163
- }
2164
- if (state.timeoutReason !== null) {
2165
- const limitDescription = state.timeoutReason === 'wall_clock'
2166
- ? `${trigger.agentConfig?.maxSessionMinutes ?? exports.DEFAULT_SESSION_TIMEOUT_MINUTES} minutes`
2167
- : `${trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS} turns`;
2168
- const result = {
2169
- _tag: 'timeout',
2170
- workflowId: trigger.workflowId,
2171
- reason: state.timeoutReason,
2172
- message: `Workflow ${state.timeoutReason === 'wall_clock' ? 'timed out' : 'exceeded turn limit'} after ${limitDescription}`,
2173
- stopReason: 'aborted',
2174
- };
2175
- await finalizeSession(result, finalizationCtx);
2176
- return result;
2177
- }
2178
- if (stopReason === 'error' || errorMessage) {
2179
- const errMsg = errorMessage ?? 'Agent stopped with error reason';
2180
- const lastToolCalled = state.lastNToolCalls.length > 0 ? state.lastNToolCalls[state.lastNToolCalls.length - 1] : null;
2181
- const stuckMarker = `\n\nWORKTRAIN_STUCK: ${JSON.stringify({
2182
- reason: 'session_error',
2183
- error: errMsg.slice(0, 500),
2184
- workflowId: trigger.workflowId,
2185
- sessionId,
2186
- turnCount: state.turnCount,
2187
- stepAdvanceCount: state.stepAdvanceCount,
2188
- ...(lastToolCalled !== null && { lastToolCalled }),
2189
- ...(state.issueSummaries.length > 0 && { issueSummaries: state.issueSummaries }),
2190
- })}`;
2191
- const result = {
2192
- _tag: 'error',
2193
- workflowId: trigger.workflowId,
2194
- message: errMsg,
2195
- stopReason,
2196
- lastStepNotes: stuckMarker,
2197
- };
2198
- await finalizeSession(result, finalizationCtx);
2199
- return result;
2200
- }
2201
- const result = {
2202
- _tag: 'success',
2203
- workflowId: trigger.workflowId,
2204
- stopReason,
2205
- ...(state.lastStepNotes !== undefined ? { lastStepNotes: state.lastStepNotes } : {}),
2206
- ...(state.lastStepArtifacts !== undefined ? { lastStepArtifacts: state.lastStepArtifacts } : {}),
2207
- ...(sessionWorktreePath !== undefined ? { sessionWorkspacePath: sessionWorktreePath } : {}),
2208
- ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
2209
- ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
2210
- };
2294
+ const result = buildSessionResult(state, stopReason, errorMessage, trigger, sessionId, sessionWorktreePath);
2211
2295
  await finalizeSession(result, finalizationCtx);
2212
2296
  return result;
2213
2297
  }