@exaudeus/workrail 3.74.1 → 3.74.2

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.
@@ -120,8 +120,6 @@ export interface WorkflowDeliveryFailed {
120
120
  }
121
121
  export type WorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout | WorkflowRunStuck | WorkflowDeliveryFailed;
122
122
  export type ChildWorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout | WorkflowRunStuck;
123
- export type SteerRegistry = Map<string, (text: string) => void>;
124
- export type AbortRegistry = Map<string, () => void>;
125
123
  export interface OrphanedSession {
126
124
  readonly sessionId: string;
127
125
  readonly continueToken: string;
@@ -159,6 +157,14 @@ export declare function buildAgentClient(trigger: WorkflowTrigger, apiKey: strin
159
157
  agentClient: Anthropic | AnthropicBedrock;
160
158
  modelId: string;
161
159
  };
160
+ export type TerminalSignal = {
161
+ readonly kind: 'stuck';
162
+ readonly reason: 'repeated_tool_call' | 'no_progress' | 'stall';
163
+ } | {
164
+ readonly kind: 'timeout';
165
+ readonly reason: 'wall_clock' | 'max_turns';
166
+ };
167
+ export declare function setTerminalSignal(state: SessionState, signal: TerminalSignal): boolean;
162
168
  export interface SessionState {
163
169
  isComplete: boolean;
164
170
  lastStepNotes: string | undefined;
@@ -172,8 +178,7 @@ export interface SessionState {
172
178
  }>;
173
179
  issueSummaries: string[];
174
180
  pendingSteerParts: string[];
175
- stuckReason: 'repeated_tool_call' | 'no_progress' | 'stall' | null;
176
- timeoutReason: 'wall_clock' | 'max_turns' | null;
181
+ terminalSignal: TerminalSignal | null;
177
182
  turnCount: number;
178
183
  }
179
184
  export declare function createSessionState(initialToken: string): SessionState;
@@ -221,6 +226,8 @@ export type PreAgentSessionResult = {
221
226
  } | {
222
227
  readonly kind: 'complete';
223
228
  readonly result: WorkflowRunResult;
229
+ readonly workrailSessionId: string | null;
230
+ readonly handle: SessionHandle | undefined;
224
231
  };
225
232
  export interface AgentReadySession {
226
233
  readonly preAgentSession: PreAgentSession;
@@ -50,6 +50,7 @@ exports.buildSystemPrompt = buildSystemPrompt;
50
50
  exports.tagToStatsOutcome = tagToStatsOutcome;
51
51
  exports.sidecardLifecycleFor = sidecardLifecycleFor;
52
52
  exports.buildAgentClient = buildAgentClient;
53
+ exports.setTerminalSignal = setTerminalSignal;
53
54
  exports.createSessionState = createSessionState;
54
55
  exports.evaluateStuckSignals = evaluateStuckSignals;
55
56
  exports.finalizeSession = finalizeSession;
@@ -880,6 +881,13 @@ function buildAgentClient(trigger, apiKey, env) {
880
881
  modelId: 'claude-sonnet-4-6',
881
882
  };
882
883
  }
884
+ function setTerminalSignal(state, signal) {
885
+ if (state.terminalSignal === null) {
886
+ state.terminalSignal = signal;
887
+ return true;
888
+ }
889
+ return false;
890
+ }
883
891
  function createSessionState(initialToken) {
884
892
  return {
885
893
  isComplete: false,
@@ -891,13 +899,12 @@ function createSessionState(initialToken) {
891
899
  lastNToolCalls: [],
892
900
  issueSummaries: [],
893
901
  pendingSteerParts: [],
894
- stuckReason: null,
895
- timeoutReason: null,
902
+ terminalSignal: null,
896
903
  turnCount: 0,
897
904
  };
898
905
  }
899
906
  function evaluateStuckSignals(state, config) {
900
- if (config.maxTurns > 0 && state.turnCount >= config.maxTurns && state.timeoutReason === null) {
907
+ if (config.maxTurns > 0 && state.turnCount >= config.maxTurns && state.terminalSignal === null) {
901
908
  return { kind: 'max_turns_exceeded' };
902
909
  }
903
910
  if (state.lastNToolCalls.length === config.stuckRepeatThreshold &&
@@ -913,8 +920,8 @@ function evaluateStuckSignals(state, config) {
913
920
  state.stepAdvanceCount === 0) {
914
921
  return { kind: 'no_progress', turnCount: state.turnCount, maxTurns: config.maxTurns };
915
922
  }
916
- if (state.timeoutReason !== null) {
917
- return { kind: 'timeout_imminent', timeoutReason: state.timeoutReason };
923
+ if (state.terminalSignal?.kind === 'timeout') {
924
+ return { kind: 'timeout_imminent', timeoutReason: state.terminalSignal.reason };
918
925
  }
919
926
  return null;
920
927
  }
@@ -1004,8 +1011,7 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1004
1011
  }
1005
1012
  catch (e) {
1006
1013
  const message = e instanceof Error ? e.message : String(e);
1007
- writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1008
- return { kind: 'complete', result: { _tag: 'error', workflowId: trigger.workflowId, message, stopReason: 'error' } };
1014
+ return { kind: 'complete', result: { _tag: 'error', workflowId: trigger.workflowId, message, stopReason: 'error' }, workrailSessionId: null, handle: undefined };
1009
1015
  }
1010
1016
  const state = createSessionState('');
1011
1017
  let continueToken;
@@ -1023,7 +1029,6 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1023
1029
  else {
1024
1030
  const startResult = await (0, start_js_1.executeStartWorkflow)({ workflowId: trigger.workflowId, workspacePath: trigger.workspacePath, goal: trigger.goal }, ctx, { is_autonomous: 'true', workspacePath: trigger.workspacePath, triggerSource: 'daemon' });
1025
1031
  if (startResult.isErr()) {
1026
- writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1027
1032
  return {
1028
1033
  kind: 'complete',
1029
1034
  result: {
@@ -1032,6 +1037,8 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1032
1037
  message: `start_workflow failed: ${startResult.error.kind} -- ${JSON.stringify(startResult.error)}`,
1033
1038
  stopReason: 'error',
1034
1039
  },
1040
+ workrailSessionId: null,
1041
+ handle: undefined,
1035
1042
  };
1036
1043
  }
1037
1044
  const r = startResult.value.response;
@@ -1057,7 +1064,6 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1057
1064
  workspacePath: trigger.workspacePath,
1058
1065
  });
1059
1066
  if (persistResult.kind === 'err') {
1060
- writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1061
1067
  return {
1062
1068
  kind: 'complete',
1063
1069
  result: {
@@ -1066,6 +1072,8 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1066
1072
  message: `Initial token persist failed: ${persistResult.error.code} -- ${persistResult.error.message}`,
1067
1073
  stopReason: 'error',
1068
1074
  },
1075
+ workrailSessionId: state.workrailSessionId,
1076
+ handle: undefined,
1069
1077
  };
1070
1078
  }
1071
1079
  }
@@ -1097,7 +1105,6 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1097
1105
  await execFileAsync('git', ['-C', trigger.workspacePath, 'worktree', 'remove', '--force', sessionWorkspacePath]);
1098
1106
  }
1099
1107
  catch { }
1100
- writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1101
1108
  return {
1102
1109
  kind: 'complete',
1103
1110
  result: {
@@ -1106,6 +1113,8 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1106
1113
  message: `Worktree sidecar persist failed: ${worktreePersistResult.error.code} -- ${worktreePersistResult.error.message}`,
1107
1114
  stopReason: 'error',
1108
1115
  },
1116
+ workrailSessionId: state.workrailSessionId,
1117
+ handle: undefined,
1109
1118
  };
1110
1119
  }
1111
1120
  console.log(`[WorkflowRunner] Worktree created: sessionId=${sessionId} branch=${branchPrefix}${sessionId} path=${sessionWorkspacePath}`);
@@ -1113,10 +1122,11 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1113
1122
  catch (e) {
1114
1123
  const errMsg = e instanceof Error ? e.message : String(e);
1115
1124
  console.error(`[WorkflowRunner] Worktree creation failed: sessionId=${sessionId} error=${errMsg}`);
1116
- writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'error', 0);
1117
1125
  return {
1118
1126
  kind: 'complete',
1119
1127
  result: { _tag: 'error', workflowId: trigger.workflowId, message: `Worktree creation failed: ${errMsg}`, stopReason: 'error' },
1128
+ workrailSessionId: state.workrailSessionId,
1129
+ handle: undefined,
1120
1130
  };
1121
1131
  }
1122
1132
  }
@@ -1126,16 +1136,6 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1126
1136
  handle = activeSessionSet?.register(state.workrailSessionId, (text) => { state.pendingSteerParts.push(text); });
1127
1137
  }
1128
1138
  if (isComplete) {
1129
- const lifecycle = sidecardLifecycleFor('success', trigger.branchStrategy);
1130
- if (lifecycle.kind === 'delete_now') {
1131
- await fs.unlink(path.join(sessionsDir, `${sessionId}.json`)).catch(() => { });
1132
- }
1133
- emitter?.emit({ kind: 'session_completed', sessionId, workflowId: trigger.workflowId, outcome: 'success', detail: 'stop', ...(0, _shared_js_1.withWorkrailSession)(state.workrailSessionId) });
1134
- if (state.workrailSessionId !== null) {
1135
- daemonRegistry?.unregister(state.workrailSessionId, 'completed');
1136
- handle?.dispose();
1137
- }
1138
- writeExecutionStats(statsDir, sessionId, trigger.workflowId, startMs, 'success', 0);
1139
1139
  return {
1140
1140
  kind: 'complete',
1141
1141
  result: {
@@ -1146,6 +1146,8 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1146
1146
  ...(sessionWorktreePath !== undefined ? { sessionId } : {}),
1147
1147
  ...(trigger.botIdentity !== undefined ? { botIdentity: trigger.botIdentity } : {}),
1148
1148
  },
1149
+ workrailSessionId: state.workrailSessionId,
1150
+ handle,
1149
1151
  };
1150
1152
  }
1151
1153
  return {
@@ -1169,14 +1171,13 @@ async function buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, st
1169
1171
  },
1170
1172
  };
1171
1173
  }
1172
- function constructTools(session, ctx, apiKey, schemas, scope) {
1173
- const { state, sessionWorkspacePath, spawnCurrentDepth, spawnMaxDepth } = session;
1174
- const { fileTracker, onAdvance, onComplete, emitter, activeSessionSet, maxIssueSummaries } = scope;
1174
+ function constructTools(ctx, apiKey, schemas, scope) {
1175
+ const { fileTracker, onAdvance, onComplete, onTokenUpdate, onIssueReported, getCurrentToken, sessionWorkspacePath, spawnCurrentDepth, spawnMaxDepth, emitter, activeSessionSet, } = scope;
1175
1176
  const sid = scope.sessionId;
1176
1177
  const workrailSid = scope.workrailSessionId;
1177
1178
  const readFileStateMap = fileTracker.toMap();
1178
1179
  return [
1179
- (0, continue_workflow_js_1.makeCompleteStepTool)(sid, ctx, () => state.currentContinueToken, onAdvance, onComplete, (t) => { state.currentContinueToken = t; }, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
1180
+ (0, continue_workflow_js_1.makeCompleteStepTool)(sid, ctx, getCurrentToken, onAdvance, onComplete, onTokenUpdate, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
1180
1181
  (0, continue_workflow_js_1.makeContinueWorkflowTool)(sid, ctx, onAdvance, onComplete, schemas, index_js_1.executeContinueWorkflow, emitter, workrailSid),
1181
1182
  (0, bash_js_1.makeBashTool)(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
1182
1183
  (0, file_tools_js_1.makeReadTool)(sessionWorkspacePath, readFileStateMap, schemas, sid, emitter, workrailSid),
@@ -1184,11 +1185,7 @@ function constructTools(session, ctx, apiKey, schemas, scope) {
1184
1185
  (0, glob_grep_js_1.makeGlobTool)(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
1185
1186
  (0, glob_grep_js_1.makeGrepTool)(sessionWorkspacePath, schemas, sid, emitter, workrailSid),
1186
1187
  (0, file_tools_js_1.makeEditTool)(sessionWorkspacePath, readFileStateMap, schemas, sid, emitter, workrailSid),
1187
- (0, report_issue_js_1.makeReportIssueTool)(sid, emitter, workrailSid, undefined, (summary) => {
1188
- if (state.issueSummaries.length < maxIssueSummaries) {
1189
- state.issueSummaries.push(summary);
1190
- }
1191
- }),
1188
+ (0, report_issue_js_1.makeReportIssueTool)(sid, emitter, workrailSid, undefined, onIssueReported),
1192
1189
  (0, spawn_agent_js_1.makeSpawnAgentTool)(sid, ctx, apiKey, workrailSid ?? '', spawnCurrentDepth, spawnMaxDepth, runWorkflow, schemas, emitter, activeSessionSet),
1193
1190
  (0, signal_coordinator_js_1.makeSignalCoordinatorTool)(sid, emitter, workrailSid),
1194
1191
  ];
@@ -1207,7 +1204,7 @@ function buildTurnEndSubscriber(ctx) {
1207
1204
  const signal = evaluateStuckSignals(ctx.state, ctx.stuckConfig);
1208
1205
  if (signal !== null) {
1209
1206
  if (signal.kind === 'max_turns_exceeded') {
1210
- ctx.state.timeoutReason = 'max_turns';
1207
+ setTerminalSignal(ctx.state, { kind: 'timeout', reason: 'max_turns' });
1211
1208
  ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'timeout_imminent', detail: 'Max-turn limit reached', ...(0, _shared_js_1.withWorkrailSession)(ctx.state.workrailSessionId) });
1212
1209
  ctx.agent.abort();
1213
1210
  return;
@@ -1215,20 +1212,22 @@ function buildTurnEndSubscriber(ctx) {
1215
1212
  else if (signal.kind === 'repeated_tool_call') {
1216
1213
  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, ...(0, _shared_js_1.withWorkrailSession)(ctx.state.workrailSessionId) });
1217
1214
  void writeStuckOutboxEntry({ workflowId: ctx.workflowId, reason: 'repeated_tool_call', ...(ctx.state.issueSummaries.length > 0 ? { issueSummaries: [...ctx.state.issueSummaries] } : {}) });
1218
- if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only' && ctx.state.stuckReason === null && ctx.state.timeoutReason === null) {
1219
- ctx.state.stuckReason = 'repeated_tool_call';
1220
- ctx.agent.abort();
1221
- return;
1215
+ if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only') {
1216
+ if (setTerminalSignal(ctx.state, { kind: 'stuck', reason: 'repeated_tool_call' })) {
1217
+ ctx.agent.abort();
1218
+ return;
1219
+ }
1222
1220
  }
1223
1221
  }
1224
1222
  else if (signal.kind === 'no_progress') {
1225
1223
  ctx.emitter?.emit({ kind: 'agent_stuck', sessionId: ctx.sessionId, reason: 'no_progress', detail: `${signal.turnCount} turns used, 0 step advances (${signal.maxTurns} turn limit)`, ...(0, _shared_js_1.withWorkrailSession)(ctx.state.workrailSessionId) });
1226
1224
  if (ctx.stuckConfig.noProgressAbortEnabled) {
1227
1225
  void writeStuckOutboxEntry({ workflowId: ctx.workflowId, reason: 'no_progress', ...(ctx.state.issueSummaries.length > 0 ? { issueSummaries: [...ctx.state.issueSummaries] } : {}) });
1228
- if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only' && ctx.state.stuckReason === null && ctx.state.timeoutReason === null) {
1229
- ctx.state.stuckReason = 'no_progress';
1230
- ctx.agent.abort();
1231
- return;
1226
+ if (ctx.stuckConfig.stuckAbortPolicy !== 'notify_only') {
1227
+ if (setTerminalSignal(ctx.state, { kind: 'stuck', reason: 'no_progress' })) {
1228
+ ctx.agent.abort();
1229
+ return;
1230
+ }
1232
1231
  }
1233
1232
  }
1234
1233
  }
@@ -1264,7 +1263,7 @@ function buildAgentCallbacks(sessionId, state, modelId, emitter, stuckRepeatThre
1264
1263
  emitter?.emit({ kind: 'tool_call_failed', sessionId, toolName, durationMs, errorMessage, ...(0, _shared_js_1.withWorkrailSession)(state.workrailSessionId) });
1265
1264
  },
1266
1265
  onStallDetected: () => {
1267
- state.stuckReason = 'stall';
1266
+ setTerminalSignal(state, { kind: 'stuck', reason: 'stall' });
1268
1267
  emitter?.emit({
1269
1268
  kind: 'agent_stuck',
1270
1269
  sessionId,
@@ -1281,27 +1280,31 @@ function buildAgentCallbacks(sessionId, state, modelId, emitter, stuckRepeatThre
1281
1280
  };
1282
1281
  }
1283
1282
  function buildSessionResult(state, stopReason, errorMessage, trigger, sessionId, sessionWorktreePath) {
1284
- if (state.stuckReason !== null) {
1285
- return {
1286
- _tag: 'stuck',
1287
- workflowId: trigger.workflowId,
1288
- reason: state.stuckReason,
1289
- message: `Session aborted: stuck heuristic fired (${state.stuckReason})`,
1290
- stopReason: 'aborted',
1291
- ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
1292
- };
1293
- }
1294
- if (state.timeoutReason !== null) {
1295
- const limitDescription = state.timeoutReason === 'wall_clock'
1296
- ? `${trigger.agentConfig?.maxSessionMinutes ?? exports.DEFAULT_SESSION_TIMEOUT_MINUTES} minutes`
1297
- : `${trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS} turns`;
1298
- return {
1299
- _tag: 'timeout',
1300
- workflowId: trigger.workflowId,
1301
- reason: state.timeoutReason,
1302
- message: `Workflow ${state.timeoutReason === 'wall_clock' ? 'timed out' : 'exceeded turn limit'} after ${limitDescription}`,
1303
- stopReason: 'aborted',
1304
- };
1283
+ if (state.terminalSignal !== null) {
1284
+ const signal = state.terminalSignal;
1285
+ if (signal.kind === 'stuck') {
1286
+ return {
1287
+ _tag: 'stuck',
1288
+ workflowId: trigger.workflowId,
1289
+ reason: signal.reason,
1290
+ message: `Session aborted: stuck heuristic fired (${signal.reason})`,
1291
+ stopReason: 'aborted',
1292
+ ...(state.issueSummaries.length > 0 ? { issueSummaries: [...state.issueSummaries] } : {}),
1293
+ };
1294
+ }
1295
+ if (signal.kind === 'timeout') {
1296
+ const limitDescription = signal.reason === 'wall_clock'
1297
+ ? `${trigger.agentConfig?.maxSessionMinutes ?? exports.DEFAULT_SESSION_TIMEOUT_MINUTES} minutes`
1298
+ : `${trigger.agentConfig?.maxTurns ?? exports.DEFAULT_MAX_TURNS} turns`;
1299
+ return {
1300
+ _tag: 'timeout',
1301
+ workflowId: trigger.workflowId,
1302
+ reason: signal.reason,
1303
+ message: `Workflow ${signal.reason === 'wall_clock' ? 'timed out' : 'exceeded turn limit'} after ${limitDescription}`,
1304
+ stopReason: 'aborted',
1305
+ };
1306
+ }
1307
+ return (0, assert_never_js_1.assertNever)(signal);
1305
1308
  }
1306
1309
  if (stopReason === 'error' || errorMessage) {
1307
1310
  const errMsg = errorMessage ?? 'Agent stopped with error reason';
@@ -1359,14 +1362,24 @@ async function buildAgentReadySession(preAgentSession, trigger, ctx, apiKey, ses
1359
1362
  fileTracker: new session_scope_js_1.DefaultFileStateTracker(preAgentSession.readFileState),
1360
1363
  onAdvance,
1361
1364
  onComplete,
1365
+ onTokenUpdate: (t) => { state.currentContinueToken = t; },
1366
+ onIssueReported: (summary) => {
1367
+ if (state.issueSummaries.length < MAX_ISSUE_SUMMARIES) {
1368
+ state.issueSummaries.push(summary);
1369
+ }
1370
+ },
1371
+ onSteer: (text) => { state.pendingSteerParts.push(text); },
1372
+ getCurrentToken: () => state.currentContinueToken,
1373
+ sessionWorkspacePath,
1374
+ spawnCurrentDepth: preAgentSession.spawnCurrentDepth,
1375
+ spawnMaxDepth: preAgentSession.spawnMaxDepth,
1362
1376
  workrailSessionId: state.workrailSessionId,
1363
1377
  emitter,
1364
1378
  sessionId,
1365
1379
  workflowId: trigger.workflowId,
1366
1380
  activeSessionSet,
1367
- maxIssueSummaries: MAX_ISSUE_SUMMARIES,
1368
1381
  };
1369
- const tools = constructTools(preAgentSession, ctx, apiKey, schemas, scope);
1382
+ const tools = constructTools(ctx, apiKey, schemas, scope);
1370
1383
  const contextLoader = new context_loader_js_1.DefaultContextLoader(loadDaemonSoul, loadWorkspaceContext, loadSessionNotes, ctx);
1371
1384
  const baseCtx = await contextLoader.loadBase(trigger);
1372
1385
  const contextBundle = await contextLoader.loadSession(startContinueToken, baseCtx);
@@ -1430,9 +1443,7 @@ async function runAgentLoop(session, trigger, conversationPath) {
1430
1443
  try {
1431
1444
  const timeoutPromise = new Promise((_, reject) => {
1432
1445
  timeoutHandle = setTimeout(() => {
1433
- if (state.timeoutReason === null) {
1434
- state.timeoutReason = 'wall_clock';
1435
- }
1446
+ setTerminalSignal(state, { kind: 'timeout', reason: 'wall_clock' });
1436
1447
  reject(new Error('Workflow timed out'));
1437
1448
  }, sessionTimeoutMs);
1438
1449
  });
@@ -1486,6 +1497,21 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, active
1486
1497
  });
1487
1498
  const preResult = await buildPreAgentSession(trigger, ctx, apiKey, sessionId, startMs, statsDir, sessionsDir, emitter, daemonRegistry, activeSessionSet, source);
1488
1499
  if (preResult.kind === 'complete') {
1500
+ const earlyCtx = {
1501
+ sessionId,
1502
+ workrailSessionId: preResult.workrailSessionId,
1503
+ startMs,
1504
+ stepAdvanceCount: 0,
1505
+ branchStrategy: trigger.branchStrategy,
1506
+ statsDir,
1507
+ sessionsDir,
1508
+ conversationPath: path.join(sessionsDir, `${sessionId}-conversation.jsonl`),
1509
+ emitter,
1510
+ daemonRegistry,
1511
+ workflowId: trigger.workflowId,
1512
+ };
1513
+ preResult.handle?.dispose();
1514
+ await finalizeSession(preResult.result, earlyCtx);
1489
1515
  return preResult.result;
1490
1516
  }
1491
1517
  const readySession = await buildAgentReadySession(preResult.session, trigger, ctx, apiKey, sessionId, emitter, daemonRegistry, activeSessionSet);
@@ -142,8 +142,8 @@
142
142
  "bytes": 1507
143
143
  },
144
144
  "application/services/workflow-interpreter.js": {
145
- "sha256": "860a147051cb6cf36720c18a30c5c6f62c7868c4dd9a0f98e413fda0b0d205a5",
146
- "bytes": 22145
145
+ "sha256": "b340f4e4c2b54b2c9ac97c99b5e67b11d472a3d16944bf77b16badb7d887b088",
146
+ "bytes": 23716
147
147
  },
148
148
  "application/services/workflow-service.d.ts": {
149
149
  "sha256": "c9c9e2ab4396c46da0f12af93133ca1e7da94bdc88f67a074d8f6c43ef0a5b3b",
@@ -473,8 +473,8 @@
473
473
  "sha256": "5fe866e54f796975dec5d8ba9983aefd86074db212d3fccd64eed04bc9f0b3da",
474
474
  "bytes": 8011
475
475
  },
476
- "console-ui/assets/index-BmDxs-a5.js": {
477
- "sha256": "8090617babfe49eb6f4b313ee45d13d862eba3ba8c9714ef805cda33918e4f08",
476
+ "console-ui/assets/index-CK8Zux9a.js": {
477
+ "sha256": "6e1d3f6c4ec56f5b9b75a780d9cf3e1a3c62b173850093fae221add3d58d0b10",
478
478
  "bytes": 768234
479
479
  },
480
480
  "console-ui/assets/index-DHrKiMCf.css": {
@@ -482,7 +482,7 @@
482
482
  "bytes": 60673
483
483
  },
484
484
  "console-ui/index.html": {
485
- "sha256": "d7ca1e4d2950572216e245c8a6a5130f8393ed3c961f067d2320202295b18183",
485
+ "sha256": "06cf7fbd1120973d829d69db8114e98fd38e28e88d9cac7c3daa8c1834d4ebd8",
486
486
  "bytes": 417
487
487
  },
488
488
  "console/standalone-console.d.ts": {
@@ -662,8 +662,8 @@
662
662
  "bytes": 247
663
663
  },
664
664
  "daemon/session-scope.d.ts": {
665
- "sha256": "1d4c5c8ad79bde16bf49d8f364f1e95a8294896caad8d9e4e16209301505496b",
666
- "bytes": 1394
665
+ "sha256": "35e102ecaeb59fbb57b715a43c030e8aba7a2e0aa757d0f828dc677910020ada",
666
+ "bytes": 1681
667
667
  },
668
668
  "daemon/session-scope.js": {
669
669
  "sha256": "2f5295aa36b8d46b162a2b1f4d6f13af00517796aa468956563a8de46e2ecd56",
@@ -774,12 +774,12 @@
774
774
  "bytes": 429
775
775
  },
776
776
  "daemon/workflow-runner.d.ts": {
777
- "sha256": "757ac2f51eb800f1b7d714fdf86695fac10322829f334166f0a083abdabe0cb1",
778
- "bytes": 13256
777
+ "sha256": "27fd8ac603948712aaf266ef57d2c165c1b4f92f877358debc40ad4f43d5b24c",
778
+ "bytes": 13463
779
779
  },
780
780
  "daemon/workflow-runner.js": {
781
- "sha256": "9d25e56934be6a7cfdfc54086faacee6e3119e7b7d01240cb958749ea33f8bcd",
782
- "bytes": 79776
781
+ "sha256": "458be357c7dd86b215aaa8eabb3fe7ddb05172a0a4a771523232dc9b012ea156",
782
+ "bytes": 80362
783
783
  },
784
784
  "di/container.d.ts": {
785
785
  "sha256": "003bb7fb7478d627524b9b1e76bd0a963a243794a687ff233b96dc0e33a06d9f",
@@ -2138,8 +2138,8 @@
2138
2138
  "bytes": 161
2139
2139
  },
2140
2140
  "v2/durable-core/domain/context-template-resolver.js": {
2141
- "sha256": "81bb0b3e94cb301ab3c24a9aa1737accdd2ad76b1a6d7a61de5ce7d4d6c34801",
2142
- "bytes": 962
2141
+ "sha256": "05a0f2f61cf666b160677e6b22faa624f290e4a9f26fae98283c8cfc5b71a546",
2142
+ "bytes": 2129
2143
2143
  },
2144
2144
  "v2/durable-core/domain/decision-trace-builder.d.ts": {
2145
2145
  "sha256": "f897dd17019bb094c72b1b19d7e731d535f66256ae38a0b08646d2604be0663d",
@@ -4,23 +4,48 @@ exports.CONTEXT_TOKEN_PATTERN = void 0;
4
4
  exports.resolveContextTemplates = resolveContextTemplates;
5
5
  exports.CONTEXT_TOKEN_PATTERN = /\{\{(?!wr\.)([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*)\}\}/;
6
6
  function resolveDotPath(base, path) {
7
- let current = base;
8
- for (const segment of path) {
9
- if (current === null || typeof current !== 'object')
10
- return undefined;
7
+ if (path.length === 0)
8
+ return { kind: 'ok', value: base };
9
+ const rootKey = path[0];
10
+ if (base === null || typeof base !== 'object') {
11
+ return { kind: 'missing_root', rootKey };
12
+ }
13
+ const rootValue = base[rootKey];
14
+ if (rootValue === undefined || rootValue === null) {
15
+ return { kind: 'missing_root', rootKey };
16
+ }
17
+ let current = rootValue;
18
+ for (let i = 1; i < path.length; i++) {
19
+ const segment = path[i];
20
+ if (current === null || typeof current !== 'object') {
21
+ const actualType = current === null ? 'null' : typeof current;
22
+ const raw = String(current);
23
+ const preview = raw.length > 60 ? raw.slice(0, 60) + '…' : raw;
24
+ return { kind: 'wrong_type', failedAtKey: path.slice(0, i).join('.'), actualType, preview };
25
+ }
11
26
  current = current[segment];
12
27
  }
13
- return current;
28
+ if (current === undefined || current === null) {
29
+ return { kind: 'leaf_missing', fullPath: path.join('.') };
30
+ }
31
+ return { kind: 'ok', value: current };
14
32
  }
15
33
  function resolveContextTemplates(template, context) {
16
34
  if (!template.includes('{{'))
17
35
  return template;
18
36
  const re = new RegExp(exports.CONTEXT_TOKEN_PATTERN.source, 'g');
19
37
  return template.replace(re, (_match, dotPath) => {
20
- const value = resolveDotPath(context, dotPath.split('.'));
21
- if (value === undefined || value === null) {
22
- return `[unset: ${dotPath}]`;
38
+ const segments = dotPath.split('.');
39
+ const result = resolveDotPath(context, segments);
40
+ switch (result.kind) {
41
+ case 'ok':
42
+ return String(result.value);
43
+ case 'missing_root':
44
+ return `[unset: ${dotPath}]`;
45
+ case 'wrong_type':
46
+ return `[unset: ${dotPath} -- '${result.failedAtKey}' is ${result.actualType} ("${result.preview}"), not object]`;
47
+ case 'leaf_missing':
48
+ return `[unset: ${dotPath}]`;
23
49
  }
24
- return String(value);
25
50
  });
26
51
  }
@@ -65,26 +65,50 @@ No proposed solutions here -- just the problem.]
65
65
 
66
66
  ### wr.coding-task forEach loop exposes broken agent-facing state (Apr 30, 2026)
67
67
 
68
- **Status: bug** | Priority: high
68
+ **Status: done** | Shipped May 1, 2026 (PR #926)
69
69
 
70
70
  **Score: 13** | Cor:3 Cap:1 Eff:2 Lev:2 Con:3 | Blocked: no
71
71
 
72
- The `phase-6-implement-slices` loop (forEach over `slices`) ran correctly mechanically -- it iterated all 8 slices and stopped. But the agent-facing representation was broken in ways that violate WorkRail's promise of consistency and determinism:
72
+ **Root cause (diagnosed Apr 30, 2026):** The agent wrote `slices` as an array of plain strings (`["1: slice name", ...]`) instead of objects (`[{name: "...", ...}]`). The engine accepted the array (it was an array), entered the loop, and `{{currentSlice.name}}` silently resolved to `[unset]` on every iteration because strings don't have a `.name` property.
73
73
 
74
- 1. **`currentSlice.name` showed `[unset]`** -- the agent was inside a forEach loop over `slices` with `itemVar: "currentSlice"`, but the template variable wasn't being projected into sessionContext before rendering. The agent couldn't see which slice it was on. This is an engine rendering issue in `buildLoopRenderContext` / `prompt-renderer.ts`.
74
+ **Shipped (PR #926):**
75
+ 1. **forEach shape guard** (`workflow-interpreter.ts`): at iteration 0, if the body uses `{{itemVar.field}}` dot-path access but the items array contains primitives, returns `LOOP_MISSING_CONTEXT` with a message naming the actual type and a preview of the bad value. The loop never enters with broken state.
76
+ 2. **Diagnostic `[unset]` messages** (`context-template-resolver.ts`): when dot-path navigation fails mid-path due to a type mismatch (e.g. `currentSlice` is a string), the rendered prompt now shows `[unset: currentSlice.name -- 'currentSlice' is string ("1: Auth..."), not object]` instead of just `[unset: currentSlice.name]`.
75
77
 
76
- 2. **Agent emitted `wr.loop_control` artifacts that had no effect** -- the forEach loop silently ignores these. The agent did useless work the engine discarded without signaling that this was happening. A correct system should either prevent the agent from emitting artifacts that can't affect the loop, or tell the agent explicitly that artifact-based exit isn't available in this loop type.
78
+ **Remaining open (separate items):** context contract enforcement (systemic fix), `todoList` abstraction, `wr.loop_control` shown in forEach prompts.
77
79
 
78
- 3. **Loop presented as "Pass N of 20" not "Slice 3 of 8"** -- the framing confused the agent about what was happening. The agent should be told it's iterating over concrete slices, not burning through a budget.
80
+ **GitHub issue:** https://github.com/EtienneBBeaulac/workrail/issues/920
81
+
82
+ ---
79
83
 
80
- The forEach loop *worked* but the agent experience was wrong. This matters because WorkRail's value is that agents should not be confused about their own loop state. An agent that emits useless artifacts, can't see its own iteration variable, and misunderstands whether the loop is progress-based or budget-based is not operating under the deterministic, correct framework WorkRail promises.
84
+ ### Context contract: steps must declare required and produced context keys (Apr 30, 2026)
81
85
 
82
- **GitHub issue:** https://github.com/EtienneBBeaulac/workrail/issues/920
86
+ **Status: tentative** | Priority: medium
87
+
88
+ **Score: 12** | Cor:3 Cap:2 Eff:1 Lev:3 Con:2 | Blocked: no
89
+
90
+ The engine has no mechanism to enforce context between steps. `Capture:` instructions in step prompts are prose -- the engine accepts `continue_workflow` with empty context on every advance, silently. This is the systemic root of the forEach `[unset]` bug: the agent wrote planning output as notes, not as context, and the engine accepted every advance without complaint. The same failure can happen in any workflow that passes state between steps.
83
91
 
84
92
  **Things to hash out:**
85
- - Is `currentSlice.name = [unset]` a bug in `buildLoopRenderContext` (engine fix needed), or is it a workflow authoring issue (the slices array items don't have a `name` property)?
86
- - Should the engine prevent agents from emitting `wr.loop_control` artifacts inside forEach loops, or simply document that they have no effect?
87
- - Should forEach loops surface iteration progress ("slice 3 of 8") differently than while loops ("pass 3 of 20") in the step header text?
93
+ - What schema format should `contextContract` use -- JSON Schema subset or a simpler workrail-specific type DSL?
94
+ - Should validation be blocking (engine rejects the advance) or advisory (engine warns in the next step prompt)?
95
+ - Does context contract cover loop entry preconditions, or does the separate forEach guard item handle that?
96
+
97
+ ---
98
+
99
+ ### `todoList` step type: ergonomic abstraction over forEach (Apr 30, 2026)
100
+
101
+ **Status: idea** | Priority: medium
102
+
103
+ **Score: 10** | Cor:2 Cap:3 Eff:1 Lev:2 Con:2 | Blocked: no
104
+
105
+ Workflow authors using forEach must manually wire a prior step to populate the items array, understand iteration variables, avoid emitting `wr.loop_control` artifacts (which have no effect in forEach), and explain the loop framing to the agent. The forEach shape guard (PR #926) now catches primitive-item arrays loudly at loop entry, but the wiring between "the step that produces items" and "the loop that consumes them" remains implicit and invisible to the engine. The `todoList` abstraction would make this wiring structural.
106
+
107
+ **Things to hash out:**
108
+ - Should `todoList` compile to a forEach loop at the engine layer, or be a new execution primitive?
109
+ - How does the setup step that produces the items array get authored -- inline prompt, routine reference, or both?
110
+ - What does the agent-facing presentation look like: "Item 3 of 8" with item content injected, or something else?
111
+ - Should `wr.loop_control` artifacts be stripped from the step prompt entirely in a `todoList`, or does the agent still need an explicit completion signal?
88
112
 
89
113
  ---
90
114
 
@@ -333,21 +357,7 @@ Five dimensions, each scored 1-3. Score = sum (max 15). Items marked **Blocked**
333
357
 
334
358
  ### `delivery_failed` unreachable in `getChildSessionResult` -- type promises more than code delivers (Apr 30, 2026)
335
359
 
336
- **Status: bug** | Priority: medium
337
-
338
- **Score: 10** | Cor:3 Cap:1 Eff:2 Lev:2 Con:2 | Blocked: no
339
-
340
- `ChildSessionResult` has `reason: 'delivery_failed'` as a variant of `kind: 'failed'`. However `fetchChildSessionResult` in `coordinator-deps.ts` reads session status through `ConsoleService.getSessionDetail`, which returns statuses like `complete`/`blocked`/`in_progress` -- it never returns a `delivery_failed` status. `delivery_failed` is a `TriggerRouter`-level concept (callbackUrl POST failure) that is not stored as a session status in the event log. Child sessions spawned via `spawnSession`/`spawnAndAwait` have no `callbackUrl` and cannot produce it through this code path.
341
-
342
- The result: coordinators using `getChildSessionResult` can never observe `reason: 'delivery_failed'`, even though the type says they might. This violates the "make illegal states unrepresentable" principle -- the type union promises a variant the implementation cannot produce on this path.
343
-
344
- **Architectural fix (not a comment):** surface `delivery_failed` through session status. When `TriggerRouter` records a `delivery_failed` outcome, write a corresponding session event or status that `ConsoleService.getSessionDetail` returns. Then `fetchChildSessionResult` can map it correctly. This closes the gap between what the type promises and what the infrastructure delivers.
345
-
346
- Alternative: if `spawnSession`/`spawnAndAwait` child sessions genuinely cannot have `delivery_failed` outcomes by design, remove `reason: 'delivery_failed'` from `ChildSessionResult` entirely and document that it only exists in `spawn_agent`'s direct outcome mapping.
347
-
348
- **Things to hash out:**
349
- - Should `delivery_failed` be surfaced through ConsoleService (requires touching session status storage), or removed from `ChildSessionResult` since the `spawnSession` path provably cannot produce it?
350
- - If surfaced: what event or field in the session store carries this status, and how does ConsoleService project it?
360
+ **Status: done** | Fixed in `cd8aaeb8` -- `delivery_failed` removed from `ChildSessionResult` entirely. The `spawnSession`/`spawnAndAwait` path cannot produce it by design; it only exists in `spawn_agent`'s direct outcome mapping.
351
361
 
352
362
  ---
353
363
 
@@ -367,19 +377,27 @@ Alternative: if `spawnSession`/`spawnAndAwait` child sessions genuinely cannot h
367
377
 
368
378
  ### Daemon architecture: remaining migrations (Apr 29, 2026)
369
379
 
370
- **Status: partial** | A9 shipped Apr 29, 2026.
380
+ **Status: partial** | A9 shipped Apr 29, 2026. FC/IS follow-on shipped Apr 30 -- May 1, 2026.
371
381
 
372
382
  **Score: 8** | Cor:1 Cap:1 Eff:2 Lev:1 Con:3 | Blocked: no
373
383
 
374
384
  Track A (A1-A9) shipped and the `SessionSource` migration is complete. `WorkflowTrigger._preAllocatedStartResponse` is gone.
375
385
 
386
+ **Shipped Apr 30 -- May 1, 2026 (PR #925):**
387
+ - `TerminalSignal` union replaces `stuckReason` + `timeoutReason`. Illegal state (stuck AND timeout simultaneously) now structurally impossible. Stall overwrite bug fixed. `Readonly<SessionState>` at pure read sites.
388
+ - `SessionScope` capability boundary complete: `onTokenUpdate`, `onIssueReported`, `onSteer`, `getCurrentToken`, `sessionWorkspacePath`, spawn depths all named scope fields. `constructTools` signature is `(ctx, apiKey, schemas, scope)` -- zero direct `state.X` references.
389
+ - Early-exit paths unified through `finalizeSession`. `SteerRegistry`/`AbortRegistry` dead exports removed.
390
+ - Architecture tests enforce `state.terminalSignal` write restriction and `constructTools` state-access restriction in CI.
391
+ - `persistTokens` failure early-exit path covered by new outcome invariants tests.
392
+
376
393
  **Remaining items:**
377
394
 
378
395
  - `CriticalEffect<T>` / `ObservabilityEffect` type distinction -- categorize side effects in `runAgentLoop` and finalization as either crash-relevant or observability-only
379
- - `StateRef` mutation wrapper -- replace direct `state.pendingSteerParts.push()` mutations with an explicit mutation API
380
396
  - Zod tool param validation -- replace manual `typeof` checks in tool factories with Zod schema validation (requires `zodToJsonSchema` or maintaining two sources of truth for param schemas)
381
397
  - `createCoordinatorDeps` unit tests -- extraction in B3 improved testability; cover `spawnSession`, `awaitSessions`, `getAgentResult` at minimum
382
398
  - ~~Wire `AllocatedSession.triggerSource` to the `run_started` event for session attribution~~ -- **done**, PR #899 (Apr 30, 2026)
399
+ - ~~`SessionStateWriter` capability interfaces~~ -- **done** as part of PR #925 (`SessionScope` now owns all mutation callbacks)
400
+ - ~~Architecture test: forbid `state.terminalSignal =` direct writes outside `setTerminalSignal()`~~ -- **done**, PR #925
383
401
 
384
402
  ---
385
403
 
@@ -444,6 +462,8 @@ Phase 3 (PRs #835, #837): `buildTurnEndSubscriber`, `buildAgentCallbacks`, `buil
444
462
 
445
463
  **Total workflow-runner.ts reduction: ~4,955 → ~2,800 lines (44%).**
446
464
 
465
+ **FC/IS follow-on (PR #925, Apr 30 -- May 1, 2026):** `TerminalSignal` union, `SessionScope` capability boundary completion, early-exit unification through `finalizeSession`, architecture tests. See "Daemon architecture: remaining migrations" entry for full details.
466
+
447
467
  **Follow-on:** `wr.refactoring` workflow (see backlog entry above). Remaining items in "Daemon architecture: remaining migrations" entry below.
448
468
 
449
469
  ---