@exaudeus/workrail 3.71.0 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 +87,12 @@ 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");
93
+ const step_injector_js_1 = require("./turn-end/step-injector.js");
94
+ const conversation_flusher_js_1 = require("./turn-end/conversation-flusher.js");
95
+ const session_scope_js_1 = require("./session-scope.js");
87
96
  const execAsync = (0, node_util_1.promisify)(node_child_process_1.exec);
88
97
  const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
89
98
  const BASH_TIMEOUT_MS = 5 * 60 * 1000;
@@ -122,22 +131,29 @@ var soul_template_js_2 = require("./soul-template.js");
122
131
  Object.defineProperty(exports, "DAEMON_SOUL_DEFAULT", { enumerable: true, get: function () { return soul_template_js_2.DAEMON_SOUL_DEFAULT; } });
123
132
  Object.defineProperty(exports, "DAEMON_SOUL_TEMPLATE", { enumerable: true, get: function () { return soul_template_js_2.DAEMON_SOUL_TEMPLATE; } });
124
133
  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);
134
+ try {
135
+ await fs.mkdir(exports.DAEMON_SESSIONS_DIR, { recursive: true });
136
+ const sessionPath = path.join(exports.DAEMON_SESSIONS_DIR, `${sessionId}.json`);
137
+ const state = JSON.stringify({
138
+ continueToken,
139
+ checkpointToken,
140
+ ts: Date.now(),
141
+ ...(worktreePath !== undefined ? { worktreePath } : {}),
142
+ ...(recoveryContext !== undefined ? {
143
+ workflowId: recoveryContext.workflowId,
144
+ goal: recoveryContext.goal,
145
+ workspacePath: recoveryContext.workspacePath,
146
+ } : {}),
147
+ }, null, 2);
148
+ const tmp = `${sessionPath}.tmp`;
149
+ await fs.writeFile(tmp, state, 'utf8');
150
+ await fs.rename(tmp, sessionPath);
151
+ return (0, result_js_1.ok)(undefined);
152
+ }
153
+ catch (e) {
154
+ const nodeErr = e;
155
+ return (0, result_js_1.err)({ code: nodeErr.code ?? 'UNKNOWN', message: nodeErr.message ?? String(e) });
156
+ }
141
157
  }
142
158
  async function appendConversationMessages(filePath, messages) {
143
159
  if (messages.length === 0)
@@ -199,7 +215,7 @@ async function readAllDaemonSessions(sessionsDir = exports.DAEMON_SESSIONS_DIR)
199
215
  }
200
216
  return sessions;
201
217
  }
202
- async function runStartupRecovery(sessionsDir = exports.DAEMON_SESSIONS_DIR, execFn = execFileAsync, ctx, _countStepAdvancesFn = countOrphanStepAdvances, _executeContinueWorkflowFn = index_js_1.executeContinueWorkflow, _runWorkflowFn = runWorkflow) {
218
+ async function runStartupRecovery(sessionsDir = exports.DAEMON_SESSIONS_DIR, execFn = execFileAsync, ctx, _countStepAdvancesFn = countOrphanStepAdvances, _executeContinueWorkflowFn = index_js_1.executeContinueWorkflow, _runWorkflowFn = runWorkflow, apiKey = '') {
203
219
  await clearQueueIssueSidecars(sessionsDir);
204
220
  const sessions = await readAllDaemonSessions(sessionsDir);
205
221
  if (sessions.length === 0) {
@@ -308,7 +324,7 @@ async function runStartupRecovery(sessionsDir = exports.DAEMON_SESSIONS_DIR, exe
308
324
  };
309
325
  console.log(`[WorkflowRunner] Startup recovery: resuming session ${session.sessionId} ` +
310
326
  `workflowId=${session.workflowId} stepAdvances=${stepAdvances}`);
311
- void _runWorkflowFn(recoveredTrigger, ctx, process.env['ANTHROPIC_API_KEY'] ?? '').then((result) => {
327
+ void _runWorkflowFn(recoveredTrigger, ctx, apiKey).then((result) => {
312
328
  console.log(`[WorkflowRunner] Startup recovery: resumed session ${session.sessionId} completed: ${result._tag}`);
313
329
  }).catch((err) => {
314
330
  console.warn(`[WorkflowRunner] Startup recovery: resumed session ${session.sessionId} failed: ` +
@@ -728,7 +744,10 @@ function makeContinueWorkflowTool(sessionId, ctx, onAdvance, onComplete, schemas
728
744
  const checkpointToken = out.checkpointToken ?? null;
729
745
  const persistToken = (out.kind === 'blocked' ? out.nextCall?.params.continueToken : undefined) ?? continueToken;
730
746
  if (persistToken) {
731
- await persistTokens(sessionId, persistToken, checkpointToken);
747
+ const persistResult = await persistTokens(sessionId, persistToken, checkpointToken);
748
+ if (persistResult.kind === 'err') {
749
+ console.warn(`[WorkflowRunner] persistTokens failed (continue_workflow): ${persistResult.error.code} -- ${persistResult.error.message}`);
750
+ }
732
751
  }
733
752
  if (out.kind === 'blocked') {
734
753
  const retryToken = out.nextCall?.params.continueToken ?? continueToken;
@@ -829,7 +848,10 @@ function makeCompleteStepTool(sessionId, ctx, getCurrentToken, onAdvance, onComp
829
848
  const checkpointToken = out.checkpointToken ?? null;
830
849
  const persistToken = (out.kind === 'blocked' ? out.nextCall?.params.continueToken : undefined) ?? newContinueToken;
831
850
  if (persistToken) {
832
- await persistTokens(sessionId, persistToken, checkpointToken);
851
+ const persistResult = await persistTokens(sessionId, persistToken, checkpointToken);
852
+ if (persistResult.kind === 'err') {
853
+ console.warn(`[WorkflowRunner] persistTokens failed (complete_step): ${persistResult.error.code} -- ${persistResult.error.message}`);
854
+ }
833
855
  }
834
856
  if (out.kind === 'blocked') {
835
857
  const retryToken = out.nextCall?.params.continueToken ?? newContinueToken;
@@ -903,6 +925,8 @@ function makeBashTool(workspacePath, schemas, sessionId, emitter, workrailSessio
903
925
  inputSchema: schemas['BashParams'],
904
926
  label: 'Bash',
905
927
  execute: async (_toolCallId, params) => {
928
+ if (typeof params.command !== 'string' || !params.command)
929
+ throw new Error('Bash: command must be a non-empty string');
906
930
  console.log(`[WorkflowRunner] Tool: bash "${String(params.command).slice(0, 80)}"`);
907
931
  if (sessionId)
908
932
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Bash', summary: String(params.command).slice(0, 80), ...withWorkrailSession(workrailSessionId) });
@@ -964,6 +988,8 @@ function makeReadTool(readFileState, schemas, sessionId, emitter, workrailSessio
964
988
  inputSchema: schemas['ReadParams'],
965
989
  label: 'Read',
966
990
  execute: async (_toolCallId, params) => {
991
+ if (typeof params.filePath !== 'string' || !params.filePath)
992
+ throw new Error('Read: filePath must be a non-empty string');
967
993
  const filePath = params.filePath;
968
994
  if (sessionId)
969
995
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Read', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
@@ -1002,6 +1028,10 @@ function makeWriteTool(readFileState, schemas, sessionId, emitter, workrailSessi
1002
1028
  inputSchema: schemas['WriteParams'],
1003
1029
  label: 'Write',
1004
1030
  execute: async (_toolCallId, params) => {
1031
+ if (typeof params.filePath !== 'string' || !params.filePath)
1032
+ throw new Error('Write: filePath must be a non-empty string');
1033
+ if (typeof params.content !== 'string')
1034
+ throw new Error('Write: content must be a string');
1005
1035
  const filePath = params.filePath;
1006
1036
  if (sessionId)
1007
1037
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'Write', summary: filePath.slice(0, 80), ...withWorkrailSession(workrailSessionId) });
@@ -1042,6 +1072,8 @@ function makeGlobTool(workspacePath, schemas, sessionId, emitter, workrailSessio
1042
1072
  inputSchema: schemas['GlobParams'],
1043
1073
  label: 'Glob',
1044
1074
  execute: async (_toolCallId, params) => {
1075
+ if (typeof params.pattern !== 'string' || !params.pattern)
1076
+ throw new Error('Glob: pattern must be a non-empty string');
1045
1077
  const pattern = params.pattern;
1046
1078
  const searchRoot = params.path ?? workspacePath;
1047
1079
  if (sessionId)
@@ -1091,6 +1123,8 @@ function makeGrepTool(workspacePath, schemas, sessionId, emitter, workrailSessio
1091
1123
  inputSchema: schemas['GrepParams'],
1092
1124
  label: 'Grep',
1093
1125
  execute: async (_toolCallId, params) => {
1126
+ if (typeof params.pattern !== 'string' || !params.pattern)
1127
+ throw new Error('Grep: pattern must be a non-empty string');
1094
1128
  const pattern = params.pattern;
1095
1129
  const searchPath = params.path ?? workspacePath;
1096
1130
  const outputMode = params.output_mode ?? 'files_with_matches';
@@ -1167,6 +1201,12 @@ function makeEditTool(workspacePath, readFileState, schemas, sessionId, emitter,
1167
1201
  inputSchema: schemas['EditParams'],
1168
1202
  label: 'Edit',
1169
1203
  execute: async (_toolCallId, params) => {
1204
+ if (typeof params.file_path !== 'string' || !params.file_path)
1205
+ throw new Error('Edit: file_path must be a non-empty string');
1206
+ if (typeof params.old_string !== 'string')
1207
+ throw new Error('Edit: old_string must be a string');
1208
+ if (typeof params.new_string !== 'string')
1209
+ throw new Error('Edit: new_string must be a string');
1170
1210
  const rawFilePath = params.file_path;
1171
1211
  const absoluteFilePath = path.isAbsolute(rawFilePath)
1172
1212
  ? rawFilePath
@@ -1236,6 +1276,12 @@ function makeSpawnAgentTool(sessionId, ctx, apiKey, thisWorkrailSessionId, curre
1236
1276
  inputSchema: schemas['SpawnAgentParams'],
1237
1277
  label: 'Spawn Agent',
1238
1278
  execute: async (_toolCallId, params) => {
1279
+ if (typeof params.workflowId !== 'string' || !params.workflowId)
1280
+ throw new Error('spawn_agent: workflowId must be a non-empty string');
1281
+ if (typeof params.goal !== 'string' || !params.goal)
1282
+ throw new Error('spawn_agent: goal must be a non-empty string');
1283
+ if (typeof params.workspacePath !== 'string' || !params.workspacePath)
1284
+ throw new Error('spawn_agent: workspacePath must be a non-empty string');
1239
1285
  console.log(`[WorkflowRunner] Tool: spawn_agent sessionId=${sessionId} workflowId=${String(params.workflowId)} depth=${currentDepth}/${maxDepth}`);
1240
1286
  emitter?.emit({ kind: 'tool_called', sessionId, toolName: 'spawn_agent', summary: `${String(params.workflowId)} depth=${currentDepth}`, ...withWorkrailSession(thisWorkrailSessionId) });
1241
1287
  if (currentDepth >= maxDepth) {
@@ -1415,6 +1461,12 @@ function makeReportIssueTool(sessionId, emitter, workrailSessionId, issuesDirOve
1415
1461
  },
1416
1462
  label: 'report_issue',
1417
1463
  execute: async (_toolCallId, params) => {
1464
+ if (typeof params.kind !== 'string' || !params.kind)
1465
+ throw new Error('report_issue: kind must be a non-empty string');
1466
+ if (typeof params.severity !== 'string' || !params.severity)
1467
+ throw new Error('report_issue: severity must be a non-empty string');
1468
+ if (typeof params.summary !== 'string' || !params.summary)
1469
+ throw new Error('report_issue: summary must be a non-empty string');
1418
1470
  const record = {
1419
1471
  sessionId,
1420
1472
  kind: params.kind,
@@ -1486,6 +1538,8 @@ function makeSignalCoordinatorTool(sessionId, emitter, workrailSessionId, signal
1486
1538
  },
1487
1539
  label: 'signal_coordinator',
1488
1540
  execute: async (_toolCallId, params) => {
1541
+ if (typeof params.signalKind !== 'string' || !params.signalKind)
1542
+ throw new Error('signal_coordinator: signalKind must be a non-empty string');
1489
1543
  const signalId = 'sig_' + (0, node_crypto_1.randomUUID)().replace(/-/g, '').slice(0, 8);
1490
1544
  const signalKind = String(params.signalKind ?? 'progress');
1491
1545
  const payload = (typeof params.payload === 'object' && params.payload !== null && !Array.isArray(params.payload))
@@ -1629,6 +1683,22 @@ function tagToStatsOutcome(tag) {
1629
1683
  default: return (0, assert_never_js_1.assertNever)(tag);
1630
1684
  }
1631
1685
  }
1686
+ function sidecardLifecycleFor(tag, branchStrategy) {
1687
+ switch (tag) {
1688
+ case 'success':
1689
+ return branchStrategy === 'worktree'
1690
+ ? { kind: 'retain_for_delivery' }
1691
+ : { kind: 'delete_now' };
1692
+ case 'error':
1693
+ case 'timeout':
1694
+ case 'stuck':
1695
+ return { kind: 'delete_now' };
1696
+ case 'delivery_failed':
1697
+ throw new Error(`sidecardLifecycleFor: delivery_failed is not a valid input (invariant 1.2)`);
1698
+ default:
1699
+ return (0, assert_never_js_1.assertNever)(tag);
1700
+ }
1701
+ }
1632
1702
  function buildAgentClient(trigger, apiKey, env) {
1633
1703
  if (trigger.agentConfig?.model) {
1634
1704
  const slashIdx = trigger.agentConfig.model.indexOf('/');
@@ -1726,9 +1796,15 @@ async function finalizeSession(result, ctx) {
1726
1796
  ctx.daemonRegistry?.unregister(ctx.workrailSessionId, result._tag === 'success' || result._tag === 'delivery_failed' ? 'completed' : 'failed');
1727
1797
  }
1728
1798
  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(() => { });
1799
+ const lifecycle = sidecardLifecycleFor(result._tag, ctx.branchStrategy);
1800
+ switch (lifecycle.kind) {
1801
+ case 'delete_now':
1802
+ await fs.unlink(path.join(ctx.sessionsDir, `${ctx.sessionId}.json`)).catch(() => { });
1803
+ break;
1804
+ case 'retain_for_delivery':
1805
+ break;
1806
+ default:
1807
+ (0, assert_never_js_1.assertNever)(lifecycle);
1732
1808
  }
1733
1809
  if (result._tag === 'success' && ctx.branchStrategy !== 'worktree') {
1734
1810
  await fs.unlink(ctx.conversationPath).catch(() => { });
@@ -1747,18 +1823,7 @@ function buildSessionContext(trigger, inputs) {
1747
1823
  const maxTurns = trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS;
1748
1824
  return { systemPrompt, initialPrompt, sessionTimeoutMs, maxTurns };
1749
1825
  }
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
- });
1826
+ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, statsDir, sessionsDir, emitter, daemonRegistry, steerRegistry) {
1762
1827
  let agentClient;
1763
1828
  let modelId;
1764
1829
  try {
@@ -1776,32 +1841,12 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1776
1841
  }
1777
1842
  }
1778
1843
  }
1779
- catch (err) {
1780
- const message = err instanceof Error ? err.message : String(err);
1844
+ catch (e) {
1845
+ const message = e instanceof Error ? e.message : String(e);
1781
1846
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1782
- return {
1783
- _tag: 'error',
1784
- workflowId: trigger.workflowId,
1785
- message,
1786
- stopReason: 'error',
1787
- };
1847
+ return { kind: 'complete', result: { _tag: 'error', workflowId: trigger.workflowId, message, stopReason: 'error' } };
1788
1848
  }
1789
1849
  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
1850
  let firstStep;
1806
1851
  if (trigger._preAllocatedStartResponse !== undefined) {
1807
1852
  firstStep = trigger._preAllocatedStartResponse;
@@ -1811,41 +1856,47 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1811
1856
  if (startResult.isErr()) {
1812
1857
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1813
1858
  return {
1814
- _tag: 'error',
1815
- workflowId: trigger.workflowId,
1816
- message: `start_workflow failed: ${startResult.error.kind} -- ${JSON.stringify(startResult.error)}`,
1817
- stopReason: 'error',
1859
+ kind: 'complete',
1860
+ result: {
1861
+ _tag: 'error',
1862
+ workflowId: trigger.workflowId,
1863
+ message: `start_workflow failed: ${startResult.error.kind} -- ${JSON.stringify(startResult.error)}`,
1864
+ stopReason: 'error',
1865
+ },
1818
1866
  };
1819
1867
  }
1820
1868
  firstStep = startResult.value.response;
1821
1869
  }
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);
1870
+ const continueToken = firstStep.continueToken ?? '';
1871
+ const checkpointToken = firstStep.checkpointToken ?? null;
1872
+ state.currentContinueToken = continueToken;
1873
+ if (continueToken) {
1874
+ const decoded = await (0, v2_token_ops_js_1.parseContinueTokenOrFail)(continueToken, ctx.v2.tokenCodecPorts, ctx.v2.tokenAliasStore);
1827
1875
  if (decoded.isOk()) {
1828
1876
  state.workrailSessionId = decoded.value.sessionId;
1829
1877
  }
1830
1878
  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}`);
1879
+ console.error(`[WorkflowRunner] Error: could not decode WorkRail session ID from continueToken -- isLive and liveActivity will not work. Reason: ${decoded.error.message}`);
1832
1880
  }
1833
1881
  }
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, {
1882
+ if (continueToken) {
1883
+ const persistResult = await persistTokens(sessionId, continueToken, checkpointToken, undefined, {
1845
1884
  workflowId: trigger.workflowId,
1846
1885
  goal: trigger.goal,
1847
1886
  workspacePath: trigger.workspacePath,
1848
1887
  });
1888
+ if (persistResult.kind === 'err') {
1889
+ writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1890
+ return {
1891
+ kind: 'complete',
1892
+ result: {
1893
+ _tag: 'error',
1894
+ workflowId: trigger.workflowId,
1895
+ message: `Initial token persist failed: ${persistResult.error.code} -- ${persistResult.error.message}`,
1896
+ stopReason: 'error',
1897
+ },
1898
+ };
1899
+ }
1849
1900
  }
1850
1901
  let sessionWorkspacePath = trigger.workspacePath;
1851
1902
  let sessionWorktreePath;
@@ -1864,113 +1915,170 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1864
1915
  '-b', `${branchPrefix}${sessionId}`,
1865
1916
  `origin/${baseBranch}`,
1866
1917
  ]);
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}`);
1918
+ const worktreePersistResult = await persistTokens(sessionId, continueToken ?? state.currentContinueToken, checkpointToken, sessionWorktreePath, { workflowId: trigger.workflowId, goal: trigger.goal, workspacePath: trigger.workspacePath });
1919
+ if (worktreePersistResult.kind === 'err') {
1920
+ console.error(`[WorkflowRunner] Worktree sidecar persist failed: ${worktreePersistResult.error.code} -- ${worktreePersistResult.error.message}`);
1921
+ try {
1922
+ await execFileAsync('git', ['-C', trigger.workspacePath, 'worktree', 'remove', '--force', sessionWorkspacePath]);
1923
+ }
1924
+ catch { }
1925
+ writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1926
+ return {
1927
+ kind: 'complete',
1928
+ result: {
1929
+ _tag: 'error',
1930
+ workflowId: trigger.workflowId,
1931
+ message: `Worktree sidecar persist failed: ${worktreePersistResult.error.code} -- ${worktreePersistResult.error.message}`,
1932
+ stopReason: 'error',
1933
+ },
1934
+ };
1935
+ }
1936
+ console.log(`[WorkflowRunner] Worktree created: sessionId=${sessionId} branch=${branchPrefix}${sessionId} path=${sessionWorkspacePath}`);
1874
1937
  }
1875
- catch (err) {
1876
- const errMsg = err instanceof Error ? err.message : String(err);
1938
+ catch (e) {
1939
+ const errMsg = e instanceof Error ? e.message : String(e);
1877
1940
  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
1941
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1886
1942
  return {
1887
- _tag: 'error',
1888
- workflowId: trigger.workflowId,
1889
- message: `Worktree creation failed: ${errMsg}`,
1890
- stopReason: 'error',
1943
+ kind: 'complete',
1944
+ result: { _tag: 'error', workflowId: trigger.workflowId, message: `Worktree creation failed: ${errMsg}`, stopReason: 'error' },
1891
1945
  };
1892
1946
  }
1893
1947
  }
1948
+ if (state.workrailSessionId !== null) {
1949
+ daemonRegistry?.register(state.workrailSessionId, trigger.workflowId);
1950
+ steerRegistry?.set(state.workrailSessionId, (text) => { state.pendingSteerParts.push(text); });
1951
+ }
1894
1952
  if (firstStep.isComplete) {
1895
- if (trigger.branchStrategy !== 'worktree') {
1953
+ const lifecycle = sidecardLifecycleFor('success', trigger.branchStrategy);
1954
+ if (lifecycle.kind === 'delete_now') {
1896
1955
  await fs.unlink(path.join(sessionsDir, `${sessionId}.json`)).catch(() => { });
1897
1956
  }
1898
1957
  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
1958
  if (state.workrailSessionId !== null) {
1959
+ daemonRegistry?.unregister(state.workrailSessionId, 'completed');
1902
1960
  steerRegistry?.delete(state.workrailSessionId);
1903
- abortRegistry?.delete(state.workrailSessionId);
1904
1961
  }
1905
1962
  writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'success', 0);
1906
1963
  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 } : {}),
1964
+ kind: 'complete',
1965
+ result: {
1966
+ _tag: 'success',
1967
+ workflowId: trigger.workflowId,
1968
+ stopReason: 'stop',
1969
+ ...(sessionWorktreePath !== undefined ? { sessionWorkspacePath: sessionWorktreePath } : {}),
1970
+ ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
1971
+ ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
1972
+ },
1913
1973
  };
1914
1974
  }
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) {
1975
+ return {
1976
+ kind: 'ready',
1977
+ session: {
1978
+ sessionId,
1979
+ workrailSessionId: state.workrailSessionId,
1980
+ continueToken,
1981
+ checkpointToken,
1982
+ sessionWorkspacePath,
1983
+ sessionWorktreePath,
1984
+ firstStep,
1985
+ state,
1986
+ spawnCurrentDepth: trigger.spawnDepth ?? 0,
1987
+ spawnMaxDepth: trigger.agentConfig?.maxSubagentDepth ?? 3,
1988
+ readFileState: new Map(),
1989
+ agentClient,
1990
+ modelId,
1991
+ startMs,
1992
+ },
1993
+ };
1994
+ }
1995
+ function constructTools(session, ctx, apiKey, schemas, scope) {
1996
+ const { state, sessionWorkspacePath, spawnCurrentDepth, spawnMaxDepth } = session;
1997
+ const { fileTracker, onAdvance, onComplete, emitter, abortRegistry, maxIssueSummaries } = scope;
1998
+ const sid = scope.sessionId;
1999
+ const workrailSid = scope.workrailSessionId;
2000
+ const readFileStateMap = fileTracker.toMap();
2001
+ return [
2002
+ makeCompleteStepTool(sid, ctx, () => state.currentContinueToken, onAdvance, onComplete, (t) => { state.currentContinueToken = t; }, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
2003
+ makeContinueWorkflowTool(sid, ctx, onAdvance, onComplete, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
2004
+ makeBashTool(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
2005
+ makeReadTool(readFileStateMap, schemas, sid, emitter, workrailSid),
2006
+ makeWriteTool(readFileStateMap, schemas, sid, emitter, workrailSid),
2007
+ makeGlobTool(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
2008
+ makeGrepTool(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
2009
+ makeEditTool(sessionWorkspacePath, readFileStateMap, schemas, sid, emitter, workrailSid),
2010
+ makeReportIssueTool(sid, emitter, workrailSid, undefined, (summary) => {
2011
+ if (state.issueSummaries.length < maxIssueSummaries) {
1930
2012
  state.issueSummaries.push(summary);
1931
2013
  }
1932
2014
  }),
1933
- makeSpawnAgentTool(sessionId, ctx, apiKey, state.workrailSessionId ?? '', spawnCurrentDepth, spawnMaxDepth, runWorkflow, schemas, emitter, abortRegistry),
1934
- makeSignalCoordinatorTool(sessionId, emitter, state.workrailSessionId),
2015
+ makeSpawnAgentTool(sid, ctx, apiKey, workrailSid ?? '', spawnCurrentDepth, spawnMaxDepth, runWorkflow, schemas, emitter, abortRegistry),
2016
+ makeSignalCoordinatorTool(sid, emitter, workrailSid),
1935
2017
  ];
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 = {
2018
+ }
2019
+ function buildTurnEndSubscriber(ctx) {
2020
+ return async (event) => {
2021
+ if (event.type !== 'turn_end')
2022
+ return;
2023
+ for (const toolResult of event.toolResults) {
2024
+ if (toolResult.isError) {
2025
+ const errorText = toolResult.result?.content[0]?.text ?? 'tool error';
2026
+ ctx.emitter?.emit({ kind: 'tool_error', sessionId: ctx.sessionId, toolName: toolResult.toolName, error: errorText.slice(0, 200), ...withWorkrailSession(ctx.state.workrailSessionId) });
2027
+ }
2028
+ }
2029
+ ctx.state.turnCount++;
2030
+ const signal = evaluateStuckSignals(ctx.state, ctx.stuckConfig);
2031
+ if (signal !== null) {
2032
+ if (signal.kind === 'max_turns_exceeded') {
2033
+ ctx.state.timeoutReason = 'max_turns';
2034
+ ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'timeout_imminent', detail: 'Max-turn limit reached', ...withWorkrailSession(ctx.state.workrailSessionId) });
2035
+ ctx.agent.abort();
2036
+ return;
2037
+ }
2038
+ else if (signal.kind === 'repeated_tool_call') {
2039
+ 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) });
2040
+ void writeStuckOutboxEntry({ workflowId: ctx.workflowId, reason: 'repeated_tool_call', ...(ctx.state.issueSummaries.length > 0 ? { issueSummaries: [...ctx.state.issueSummaries] } : {}) });
2041
+ if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only' && ctx.state.stuckReason === null && ctx.state.timeoutReason === null) {
2042
+ ctx.state.stuckReason = 'repeated_tool_call';
2043
+ ctx.agent.abort();
2044
+ return;
2045
+ }
2046
+ }
2047
+ else if (signal.kind === 'no_progress') {
2048
+ 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) });
2049
+ if (ctx.stuckConfig.noProgressAbortEnabled) {
2050
+ void writeStuckOutboxEntry({ workflowId: ctx.workflowId, reason: 'no_progress', ...(ctx.state.issueSummaries.length > 0 ? { issueSummaries: [...ctx.state.issueSummaries] } : {}) });
2051
+ if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only' && ctx.state.stuckReason === null && ctx.state.timeoutReason === null) {
2052
+ ctx.state.stuckReason = 'no_progress';
2053
+ ctx.agent.abort();
2054
+ return;
2055
+ }
2056
+ }
2057
+ }
2058
+ else if (signal.kind === 'timeout_imminent') {
2059
+ 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) });
2060
+ }
2061
+ else {
2062
+ (0, assert_never_js_1.assertNever)(signal);
2063
+ }
2064
+ }
2065
+ (0, conversation_flusher_js_1.flushConversation)(ctx.agent.state.messages, ctx.lastFlushedRef, ctx.conversationPath, appendConversationMessages);
2066
+ (0, step_injector_js_1.injectPendingSteps)(ctx.state, ctx.agent);
2067
+ };
2068
+ }
2069
+ function buildAgentCallbacks(sessionId, state, modelId, emitter, stuckRepeatThreshold) {
2070
+ return {
1948
2071
  onLlmTurnStarted: ({ messageCount }) => {
1949
- emitter?.emit({
1950
- kind: 'llm_turn_started',
1951
- sessionId,
1952
- messageCount,
1953
- modelId,
1954
- ...withWorkrailSession(state.workrailSessionId),
1955
- });
2072
+ emitter?.emit({ kind: 'llm_turn_started', sessionId, messageCount, modelId, ...withWorkrailSession(state.workrailSessionId) });
1956
2073
  },
1957
2074
  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
- });
2075
+ emitter?.emit({ kind: 'llm_turn_completed', sessionId, stopReason, outputTokens, inputTokens, toolNamesRequested, ...withWorkrailSession(state.workrailSessionId) });
1967
2076
  },
1968
2077
  onToolCallStarted: ({ toolName, argsSummary }) => {
1969
2078
  emitter?.emit({ kind: 'tool_call_started', sessionId, toolName, argsSummary, ...withWorkrailSession(state.workrailSessionId) });
1970
2079
  state.lastNToolCalls.push({ toolName, argsSummary });
1971
- if (state.lastNToolCalls.length > STUCK_REPEAT_THRESHOLD) {
2080
+ if (state.lastNToolCalls.length > stuckRepeatThreshold)
1972
2081
  state.lastNToolCalls.shift();
1973
- }
1974
2082
  },
1975
2083
  onToolCallCompleted: ({ toolName, durationMs, resultSummary }) => {
1976
2084
  emitter?.emit({ kind: 'tool_call_completed', sessionId, toolName, durationMs, resultSummary, ...withWorkrailSession(state.workrailSessionId) });
@@ -1979,6 +2087,125 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1979
2087
  emitter?.emit({ kind: 'tool_call_failed', sessionId, toolName, durationMs, errorMessage, ...withWorkrailSession(state.workrailSessionId) });
1980
2088
  },
1981
2089
  };
2090
+ }
2091
+ function buildSessionResult(state, stopReason, errorMessage, trigger, sessionId, sessionWorktreePath) {
2092
+ if (state.stuckReason !== null) {
2093
+ return {
2094
+ _tag: 'stuck',
2095
+ workflowId: trigger.workflowId,
2096
+ reason: state.stuckReason,
2097
+ message: `Session aborted: stuck heuristic fired (${state.stuckReason})`,
2098
+ stopReason: 'aborted',
2099
+ ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
2100
+ };
2101
+ }
2102
+ if (state.timeoutReason !== null) {
2103
+ const limitDescription = state.timeoutReason === 'wall_clock'
2104
+ ? `${trigger.agentConfig?.maxSessionMinutes ?? exports.DEFAULT_SESSION_TIMEOUT_MINUTES} minutes`
2105
+ : `${trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS} turns`;
2106
+ return {
2107
+ _tag: 'timeout',
2108
+ workflowId: trigger.workflowId,
2109
+ reason: state.timeoutReason,
2110
+ message: `Workflow ${state.timeoutReason === 'wall_clock' ? 'timed out' : 'exceeded turn limit'} after ${limitDescription}`,
2111
+ stopReason: 'aborted',
2112
+ };
2113
+ }
2114
+ if (stopReason === 'error' || errorMessage) {
2115
+ const errMsg = errorMessage ?? 'Agent stopped with error reason';
2116
+ const lastToolCalled = state.lastNToolCalls.length > 0 ? state.lastNToolCalls[state.lastNToolCalls.length - 1] : null;
2117
+ const stuckMarker = `\n\nWORKTRAIN_STUCK: ${JSON.stringify({
2118
+ reason: 'session_error',
2119
+ error: errMsg.slice(0, 500),
2120
+ workflowId: trigger.workflowId,
2121
+ sessionId,
2122
+ turnCount: state.turnCount,
2123
+ stepAdvanceCount: state.stepAdvanceCount,
2124
+ ...(lastToolCalled !== null && { lastToolCalled }),
2125
+ ...(state.issueSummaries.length > 0 && { issueSummaries: state.issueSummaries }),
2126
+ })}`;
2127
+ return {
2128
+ _tag: 'error',
2129
+ workflowId: trigger.workflowId,
2130
+ message: errMsg,
2131
+ stopReason,
2132
+ lastStepNotes: stuckMarker,
2133
+ };
2134
+ }
2135
+ return {
2136
+ _tag: 'success',
2137
+ workflowId: trigger.workflowId,
2138
+ stopReason,
2139
+ ...(state.lastStepNotes !== undefined ? { lastStepNotes: state.lastStepNotes } : {}),
2140
+ ...(state.lastStepArtifacts !== undefined ? { lastStepArtifacts: state.lastStepArtifacts } : {}),
2141
+ ...(sessionWorktreePath !== undefined ? { sessionWorkspacePath: sessionWorktreePath } : {}),
2142
+ ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
2143
+ ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
2144
+ };
2145
+ }
2146
+ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerRegistry, abortRegistry, _statsDir, _sessionsDir) {
2147
+ const statsDir = _statsDir ?? DAEMON_STATS_DIR;
2148
+ const sessionsDir = _sessionsDir ?? exports.DAEMON_SESSIONS_DIR;
2149
+ const startMs = Date.now();
2150
+ const sessionId = (0, node_crypto_1.randomUUID)();
2151
+ console.log(`[WorkflowRunner] Session started: sessionId=${sessionId} workflowId=${trigger.workflowId}`);
2152
+ emitter?.emit({
2153
+ kind: 'session_started',
2154
+ sessionId,
2155
+ workflowId: trigger.workflowId,
2156
+ workspacePath: trigger.workspacePath,
2157
+ });
2158
+ const preResult = await buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, statsDir, sessionsDir, emitter, daemonRegistry, steerRegistry);
2159
+ if (preResult.kind === 'complete') {
2160
+ return preResult.result;
2161
+ }
2162
+ const session = preResult.session;
2163
+ const { state, firstStep, sessionWorkspacePath, sessionWorktreePath, agentClient, modelId } = session;
2164
+ const startContinueToken = session.continueToken;
2165
+ const MAX_ISSUE_SUMMARIES = 10;
2166
+ const STUCK_REPEAT_THRESHOLD = 3;
2167
+ const onAdvance = (stepText, continueToken) => {
2168
+ state.pendingSteerParts.push(stepText);
2169
+ state.stepAdvanceCount++;
2170
+ state.currentContinueToken = continueToken;
2171
+ if (state.workrailSessionId !== null)
2172
+ daemonRegistry?.heartbeat(state.workrailSessionId);
2173
+ emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(state.workrailSessionId) });
2174
+ };
2175
+ const onComplete = (notes, artifacts) => {
2176
+ state.isComplete = true;
2177
+ state.lastStepNotes = notes;
2178
+ state.lastStepArtifacts = artifacts;
2179
+ state.stepAdvanceCount++;
2180
+ if (state.workrailSessionId !== null)
2181
+ daemonRegistry?.heartbeat(state.workrailSessionId);
2182
+ emitter?.emit({ kind: 'step_advanced', sessionId, ...withWorkrailSession(state.workrailSessionId) });
2183
+ };
2184
+ const schemas = getSchemas();
2185
+ const scope = {
2186
+ fileTracker: new session_scope_js_1.DefaultFileStateTracker(session.readFileState),
2187
+ onAdvance,
2188
+ onComplete,
2189
+ workrailSessionId: state.workrailSessionId,
2190
+ emitter,
2191
+ sessionId,
2192
+ workflowId: trigger.workflowId,
2193
+ abortRegistry,
2194
+ maxIssueSummaries: MAX_ISSUE_SUMMARIES,
2195
+ };
2196
+ const tools = constructTools(session, ctx, apiKey, schemas, scope);
2197
+ const [soulContent, workspaceContext, sessionNotes] = await Promise.all([
2198
+ loadDaemonSoul(trigger.soulFile),
2199
+ loadWorkspaceContext(trigger.workspacePath),
2200
+ startContinueToken ? loadSessionNotes(startContinueToken, ctx) : Promise.resolve([]),
2201
+ ]);
2202
+ const sessionCtx = buildSessionContext(trigger, {
2203
+ soulContent,
2204
+ workspaceContext,
2205
+ sessionNotes,
2206
+ firstStepPrompt: firstStep.pending?.prompt ?? 'No step content available',
2207
+ });
2208
+ const agentCallbacks = buildAgentCallbacks(sessionId, state, modelId, emitter, STUCK_REPEAT_THRESHOLD);
1982
2209
  const agent = new agent_loop_js_1.AgentLoop({
1983
2210
  systemPrompt: sessionCtx.systemPrompt,
1984
2211
  modelId,
@@ -1990,6 +2217,9 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1990
2217
  ? { maxTokens: trigger.agentConfig.maxOutputTokens }
1991
2218
  : {}),
1992
2219
  });
2220
+ if (state.workrailSessionId !== null) {
2221
+ abortRegistry?.set(state.workrailSessionId, () => { agent.abort(); });
2222
+ }
1993
2223
  const { sessionTimeoutMs, maxTurns } = sessionCtx;
1994
2224
  const stuckConfig = {
1995
2225
  maxTurns,
@@ -1998,96 +2228,18 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1998
2228
  stuckRepeatThreshold: STUCK_REPEAT_THRESHOLD,
1999
2229
  };
2000
2230
  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
- });
2231
+ const lastFlushedRef = { count: 0 };
2232
+ const unsubscribe = agent.subscribe(buildTurnEndSubscriber({
2233
+ agent,
2234
+ state,
2235
+ stuckConfig,
2236
+ sessionId,
2237
+ workflowId: trigger.workflowId,
2238
+ emitter,
2239
+ conversationPath,
2240
+ lastFlushedRef,
2241
+ stuckRepeatThreshold: STUCK_REPEAT_THRESHOLD,
2242
+ }));
2091
2243
  let stopReason = 'stop';
2092
2244
  let errorMessage;
2093
2245
  let timeoutHandle;
@@ -2124,7 +2276,7 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
2124
2276
  }
2125
2277
  finally {
2126
2278
  unsubscribe();
2127
- const remainingMessages = agent.state.messages.slice(lastFlushedMessageCount);
2279
+ const remainingMessages = agent.state.messages.slice(lastFlushedRef.count);
2128
2280
  void appendConversationMessages(conversationPath, remainingMessages).catch(() => { });
2129
2281
  if (timeoutHandle !== undefined)
2130
2282
  clearTimeout(timeoutHandle);
@@ -2149,65 +2301,7 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
2149
2301
  daemonRegistry,
2150
2302
  workflowId: trigger.workflowId,
2151
2303
  };
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
- };
2304
+ const result = buildSessionResult(state, stopReason, errorMessage, trigger, sessionId, sessionWorktreePath);
2211
2305
  await finalizeSession(result, finalizationCtx);
2212
2306
  return result;
2213
2307
  }