@exaudeus/workrail 3.44.0 → 3.46.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.
Files changed (40) hide show
  1. package/dist/cli/commands/index.d.ts +1 -0
  2. package/dist/cli/commands/index.js +3 -1
  3. package/dist/cli/commands/worktrain-pipeline.d.ts +17 -0
  4. package/dist/cli/commands/worktrain-pipeline.js +121 -0
  5. package/dist/console-ui/assets/{index-Bi38ITiQ.js → index-BQFhoMcY.js} +1 -1
  6. package/dist/console-ui/index.html +1 -1
  7. package/dist/coordinators/adaptive-pipeline.d.ts +57 -0
  8. package/dist/coordinators/adaptive-pipeline.js +104 -0
  9. package/dist/coordinators/modes/full-pipeline.d.ts +4 -0
  10. package/dist/coordinators/modes/full-pipeline.js +256 -0
  11. package/dist/coordinators/modes/implement-shared.d.ts +4 -0
  12. package/dist/coordinators/modes/implement-shared.js +201 -0
  13. package/dist/coordinators/modes/implement.d.ts +3 -0
  14. package/dist/coordinators/modes/implement.js +108 -0
  15. package/dist/coordinators/modes/quick-review.d.ts +3 -0
  16. package/dist/coordinators/modes/quick-review.js +37 -0
  17. package/dist/coordinators/modes/review-only.d.ts +2 -0
  18. package/dist/coordinators/modes/review-only.js +28 -0
  19. package/dist/coordinators/routing/route-task.d.ts +21 -0
  20. package/dist/coordinators/routing/route-task.js +55 -0
  21. package/dist/daemon/workflow-runner.d.ts +12 -2
  22. package/dist/daemon/workflow-runner.js +96 -13
  23. package/dist/manifest.json +101 -29
  24. package/dist/mcp/output-schemas.d.ts +16 -16
  25. package/dist/trigger/notification-service.d.ts +1 -1
  26. package/dist/trigger/notification-service.js +4 -0
  27. package/dist/trigger/trigger-router.d.ts +3 -0
  28. package/dist/trigger/trigger-router.js +17 -0
  29. package/dist/trigger/types.d.ts +2 -0
  30. package/dist/v2/durable-core/schemas/artifacts/discovery-handoff.d.ts +29 -0
  31. package/dist/v2/durable-core/schemas/artifacts/discovery-handoff.js +26 -0
  32. package/dist/v2/durable-core/schemas/artifacts/index.d.ts +2 -1
  33. package/dist/v2/durable-core/schemas/artifacts/index.js +7 -1
  34. package/dist/v2/durable-core/schemas/compiled-workflow/index.d.ts +8 -8
  35. package/dist/v2/usecases/console-routes.js +3 -0
  36. package/docs/design/design-candidates-stuck-escalation.md +183 -0
  37. package/docs/design/design-review-findings-stuck-escalation.md +93 -0
  38. package/docs/design/implementation-plan-stuck-escalation.md +172 -0
  39. package/docs/ideas/backlog.md +86 -0
  40. package/package.json +1 -1
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.touchesUI = touchesUI;
4
+ exports.runImplementPipeline = runImplementPipeline;
5
+ const adaptive_pipeline_js_1 = require("../adaptive-pipeline.js");
6
+ const implement_shared_js_1 = require("./implement-shared.js");
7
+ const PR_POLL_TIMEOUT_MS = 5 * 60 * 1000;
8
+ const UI_KEYWORDS = [
9
+ 'ui', 'screen', 'view', 'layout', 'component', 'design', 'ux', 'frontend',
10
+ ];
11
+ function touchesUI(goal) {
12
+ const lower = goal.toLowerCase();
13
+ return UI_KEYWORDS.some((kw) => lower.includes(kw));
14
+ }
15
+ async function runImplementPipeline(deps, opts, pitchPath, coordinatorStartMs) {
16
+ deps.stderr(`[implement] Starting IMPLEMENT pipeline for workspace=${opts.workspace}`);
17
+ const archiveDir = opts.workspace + '/.workrail/used-pitches';
18
+ const archiveTimestamp = deps.nowIso().replace(/[:.]/g, '-');
19
+ const archivePath = archiveDir + '/pitch-' + archiveTimestamp + '.md';
20
+ let outcome;
21
+ try {
22
+ outcome = await runImplementCore(deps, opts, pitchPath, coordinatorStartMs);
23
+ }
24
+ finally {
25
+ try {
26
+ await deps.mkdir(archiveDir, { recursive: true });
27
+ await deps.archiveFile(pitchPath, archivePath);
28
+ deps.stderr(`[implement] Pitch archived to ${archivePath}`);
29
+ }
30
+ catch (e) {
31
+ deps.stderr(`[WARN implement] Failed to archive pitch.md: ${e instanceof Error ? e.message : String(e)}`);
32
+ }
33
+ }
34
+ return outcome;
35
+ }
36
+ async function runImplementCore(deps, opts, pitchPath, coordinatorStartMs) {
37
+ if (touchesUI(opts.goal)) {
38
+ deps.stderr(`[implement] UX signals detected in goal, dispatching ui-ux-design-workflow`);
39
+ const cutoffCheck = (0, adaptive_pipeline_js_1.checkSpawnCutoff)(coordinatorStartMs, deps.now(), 'ux-gate');
40
+ if (cutoffCheck)
41
+ return cutoffCheck;
42
+ const uxSpawnResult = await deps.spawnSession('ui-ux-design-workflow', opts.goal, opts.workspace, {
43
+ pitchPath,
44
+ });
45
+ if (uxSpawnResult.kind === 'err') {
46
+ deps.stderr(`[implement] UX gate spawn failed: ${uxSpawnResult.error}`);
47
+ return {
48
+ kind: 'escalated',
49
+ escalationReason: { phase: 'ux-gate', reason: `UX gate spawn failed: ${uxSpawnResult.error}` },
50
+ };
51
+ }
52
+ const uxHandle = uxSpawnResult.value;
53
+ const uxAwait = await deps.awaitSessions([uxHandle], adaptive_pipeline_js_1.REVIEW_TIMEOUT_MS);
54
+ const uxResult = uxAwait.results[0];
55
+ if (!uxResult || uxResult.outcome !== 'success') {
56
+ const outcome = uxResult?.outcome ?? 'not_found';
57
+ return {
58
+ kind: 'escalated',
59
+ escalationReason: { phase: 'ux-gate', reason: `UX design session ${outcome}` },
60
+ };
61
+ }
62
+ deps.stderr(`[implement] UX design session completed`);
63
+ }
64
+ const cutoffCheck = (0, adaptive_pipeline_js_1.checkSpawnCutoff)(coordinatorStartMs, deps.now(), 'coding');
65
+ if (cutoffCheck)
66
+ return cutoffCheck;
67
+ deps.stderr(`[implement] Spawning coding-task-workflow-agentic`);
68
+ const codingSpawnResult = await deps.spawnSession('coding-task-workflow-agentic', opts.goal, opts.workspace, {
69
+ pitchPath,
70
+ });
71
+ if (codingSpawnResult.kind === 'err') {
72
+ return {
73
+ kind: 'escalated',
74
+ escalationReason: { phase: 'coding', reason: `coding session spawn failed: ${codingSpawnResult.error}` },
75
+ };
76
+ }
77
+ const codingHandle = codingSpawnResult.value;
78
+ if (!codingHandle) {
79
+ return {
80
+ kind: 'escalated',
81
+ escalationReason: { phase: 'coding', reason: 'coding session returned empty handle (zombie detection)' },
82
+ };
83
+ }
84
+ const codingAwait = await deps.awaitSessions([codingHandle], adaptive_pipeline_js_1.CODING_TIMEOUT_MS);
85
+ const codingResult = codingAwait.results[0];
86
+ if (!codingResult || codingResult.outcome !== 'success') {
87
+ const outcome = codingResult?.outcome ?? 'not_found';
88
+ return {
89
+ kind: 'escalated',
90
+ escalationReason: { phase: 'coding', reason: `coding session ${outcome}` },
91
+ };
92
+ }
93
+ deps.stderr(`[implement] Coding session completed (${Math.round((codingResult.durationMs ?? 0) / 1000)}s)`);
94
+ const branchPattern = `worktrain/${codingHandle.slice(0, 16)}`;
95
+ deps.stderr(`[implement] Polling for PR on branch pattern: ${branchPattern}`);
96
+ const prUrl = await deps.pollForPR(branchPattern, PR_POLL_TIMEOUT_MS);
97
+ if (!prUrl) {
98
+ return {
99
+ kind: 'escalated',
100
+ escalationReason: {
101
+ phase: 'pr-detection',
102
+ reason: `no PR found matching ${branchPattern} within ${PR_POLL_TIMEOUT_MS / 60000} minutes`,
103
+ },
104
+ };
105
+ }
106
+ deps.stderr(`[implement] PR detected: ${prUrl}`);
107
+ return (0, implement_shared_js_1.runReviewAndVerdictCycle)(deps, opts, prUrl, coordinatorStartMs, 0);
108
+ }
@@ -0,0 +1,3 @@
1
+ import type { AdaptiveCoordinatorDeps, AdaptivePipelineOpts, PipelineOutcome } from '../adaptive-pipeline.js';
2
+ export declare function runQuickReviewPipeline(deps: AdaptiveCoordinatorDeps, opts: AdaptivePipelineOpts, prNumbers: readonly number[], _coordinatorStartMs: number): Promise<PipelineOutcome>;
3
+ export declare function buildDepBumpGoal(prNumbers: readonly number[], originalGoal: string): string;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runQuickReviewPipeline = runQuickReviewPipeline;
4
+ exports.buildDepBumpGoal = buildDepBumpGoal;
5
+ const pr_review_js_1 = require("../pr-review.js");
6
+ async function runQuickReviewPipeline(deps, opts, prNumbers, _coordinatorStartMs) {
7
+ deps.stderr(`[quick-review] Dep-bump review for PR(s) [${prNumbers.join(', ')}]`);
8
+ const depBumpGoal = buildDepBumpGoal(prNumbers, opts.goal);
9
+ deps.stderr(`[quick-review] Goal: "${depBumpGoal.slice(0, 100)}"`);
10
+ const result = await (0, pr_review_js_1.runPrReviewCoordinator)(deps, {
11
+ workspace: opts.workspace,
12
+ prs: prNumbers.length > 0 ? [...prNumbers] : undefined,
13
+ dryRun: opts.dryRun ?? false,
14
+ port: opts.port,
15
+ });
16
+ if (result.hasErrors || result.escalated > 0) {
17
+ return {
18
+ kind: 'escalated',
19
+ escalationReason: {
20
+ phase: 'review',
21
+ reason: result.hasErrors
22
+ ? `dep-bump review coordinator reported errors (escalated=${result.escalated})`
23
+ : `${result.escalated} dep-bump PR(s) escalated during review`,
24
+ },
25
+ };
26
+ }
27
+ return {
28
+ kind: 'merged',
29
+ prUrl: result.mergedPrs.length > 0 ? `PR #${result.mergedPrs[0]}` : null,
30
+ };
31
+ }
32
+ function buildDepBumpGoal(prNumbers, originalGoal) {
33
+ const firstPr = prNumbers[0];
34
+ const prRef = firstPr !== undefined ? `PR #${firstPr}` : 'dep-bump PR';
35
+ const prTitle = originalGoal.slice(0, 80);
36
+ return `[DEP BUMP] Review ${prRef}: ${prTitle} -- skip architecture audit, verify version compatibility and test coverage only`;
37
+ }
@@ -0,0 +1,2 @@
1
+ import type { AdaptiveCoordinatorDeps, AdaptivePipelineOpts, PipelineOutcome } from '../adaptive-pipeline.js';
2
+ export declare function runReviewOnlyPipeline(deps: AdaptiveCoordinatorDeps, opts: AdaptivePipelineOpts, prNumbers: readonly number[], _coordinatorStartMs: number): Promise<PipelineOutcome>;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runReviewOnlyPipeline = runReviewOnlyPipeline;
4
+ const pr_review_js_1 = require("../pr-review.js");
5
+ async function runReviewOnlyPipeline(deps, opts, prNumbers, _coordinatorStartMs) {
6
+ deps.stderr(`[review-only] Delegating to runPrReviewCoordinator() for ${prNumbers.length > 0 ? `PR(s) [${prNumbers.join(', ')}]` : 'all open PRs'}`);
7
+ const result = await (0, pr_review_js_1.runPrReviewCoordinator)(deps, {
8
+ workspace: opts.workspace,
9
+ prs: prNumbers.length > 0 ? [...prNumbers] : undefined,
10
+ dryRun: opts.dryRun ?? false,
11
+ port: opts.port,
12
+ });
13
+ if (result.hasErrors || result.escalated > 0) {
14
+ return {
15
+ kind: 'escalated',
16
+ escalationReason: {
17
+ phase: 'review',
18
+ reason: result.hasErrors
19
+ ? `review coordinator reported errors (escalated=${result.escalated})`
20
+ : `${result.escalated} PR(s) escalated during review`,
21
+ },
22
+ };
23
+ }
24
+ return {
25
+ kind: 'merged',
26
+ prUrl: result.mergedPrs.length > 0 ? `PR #${result.mergedPrs[0]}` : null,
27
+ };
28
+ }
@@ -0,0 +1,21 @@
1
+ export type PipelineMode = {
2
+ readonly kind: 'QUICK_REVIEW';
3
+ readonly prNumbers: readonly number[];
4
+ } | {
5
+ readonly kind: 'REVIEW_ONLY';
6
+ readonly prNumbers: readonly number[];
7
+ } | {
8
+ readonly kind: 'IMPLEMENT';
9
+ readonly pitchPath: string;
10
+ } | {
11
+ readonly kind: 'FULL';
12
+ readonly goal: string;
13
+ } | {
14
+ readonly kind: 'ESCALATE';
15
+ readonly reason: string;
16
+ };
17
+ export interface RoutingDeps {
18
+ readonly fileExists: (path: string) => boolean;
19
+ }
20
+ export declare function extractPrNumbers(goal: string): number[];
21
+ export declare function routeTask(goal: string, workspace: string, deps: RoutingDeps, triggerProvider?: string): PipelineMode;
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractPrNumbers = extractPrNumbers;
4
+ exports.routeTask = routeTask;
5
+ const DEP_BUMP_KEYWORDS = [
6
+ 'bump',
7
+ 'chore:',
8
+ 'dependabot',
9
+ 'dependency upgrade',
10
+ ];
11
+ const PR_REGEX = /\bPR\s*#\d+\b/i;
12
+ const MR_REGEX = /\bMR\s*!?\d+\b/i;
13
+ const PITCH_FILE_PATH = '.workrail/current-pitch.md';
14
+ function extractPrNumbers(goal) {
15
+ const numbers = [];
16
+ const prMatches = goal.matchAll(/\bPR\s*#(\d+)\b/gi);
17
+ for (const match of prMatches) {
18
+ const n = parseInt(match[1], 10);
19
+ if (!isNaN(n))
20
+ numbers.push(n);
21
+ }
22
+ const mrMatches = goal.matchAll(/\bMR\s*!?#?(\d+)\b/gi);
23
+ for (const match of mrMatches) {
24
+ const n = parseInt(match[1], 10);
25
+ if (!isNaN(n))
26
+ numbers.push(n);
27
+ }
28
+ return numbers;
29
+ }
30
+ function hasDependencyBumpKeywords(goal) {
31
+ const lower = goal.toLowerCase();
32
+ return DEP_BUMP_KEYWORDS.some((kw) => lower.includes(kw));
33
+ }
34
+ function hasPrOrMrReference(goal) {
35
+ return PR_REGEX.test(goal) || MR_REGEX.test(goal);
36
+ }
37
+ function routeTask(goal, workspace, deps, triggerProvider) {
38
+ const prNumbers = extractPrNumbers(goal);
39
+ const hasDepBump = hasDependencyBumpKeywords(goal);
40
+ const hasPrRef = hasPrOrMrReference(goal);
41
+ const isGithubPrsPoll = triggerProvider === 'github_prs_poll';
42
+ if (hasDepBump && hasPrRef) {
43
+ return { kind: 'QUICK_REVIEW', prNumbers };
44
+ }
45
+ if (hasPrRef || isGithubPrsPoll) {
46
+ return { kind: 'REVIEW_ONLY', prNumbers };
47
+ }
48
+ const pitchPath = workspace.endsWith('/')
49
+ ? workspace + PITCH_FILE_PATH
50
+ : workspace + '/' + PITCH_FILE_PATH;
51
+ if (deps.fileExists(pitchPath)) {
52
+ return { kind: 'IMPLEMENT', pitchPath };
53
+ }
54
+ return { kind: 'FULL', goal };
55
+ }
@@ -24,6 +24,8 @@ export interface WorkflowTrigger {
24
24
  readonly maxSessionMinutes?: number;
25
25
  readonly maxTurns?: number;
26
26
  readonly maxSubagentDepth?: number;
27
+ readonly stuckAbortPolicy?: 'abort' | 'notify_only';
28
+ readonly noProgressAbortEnabled?: boolean;
27
29
  };
28
30
  readonly _preAllocatedStartResponse?: import('zod').infer<typeof V2StartWorkflowOutputSchema>;
29
31
  readonly parentSessionId?: string;
@@ -60,14 +62,22 @@ export interface WorkflowRunTimeout {
60
62
  readonly message: string;
61
63
  readonly stopReason: string;
62
64
  }
65
+ export interface WorkflowRunStuck {
66
+ readonly _tag: 'stuck';
67
+ readonly workflowId: string;
68
+ readonly reason: 'repeated_tool_call' | 'no_progress';
69
+ readonly message: string;
70
+ readonly stopReason: string;
71
+ readonly issueSummaries?: readonly string[];
72
+ }
63
73
  export interface WorkflowDeliveryFailed {
64
74
  readonly _tag: 'delivery_failed';
65
75
  readonly workflowId: string;
66
76
  readonly stopReason: string;
67
77
  readonly deliveryError: string;
68
78
  }
69
- export type WorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout | WorkflowDeliveryFailed;
70
- export type ChildWorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout;
79
+ export type WorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout | WorkflowRunStuck | WorkflowDeliveryFailed;
80
+ export type ChildWorkflowRunResult = WorkflowRunSuccess | WorkflowRunError | WorkflowRunTimeout | WorkflowRunStuck;
71
81
  export type SteerRegistry = Map<string, (text: string) => void>;
72
82
  export interface OrphanedSession {
73
83
  readonly sessionId: string;
@@ -1081,6 +1081,16 @@ function makeSpawnAgentTool(sessionId, ctx, apiKey, thisWorkrailSessionId, curre
1081
1081
  notes: childResult.message,
1082
1082
  };
1083
1083
  }
1084
+ else if (childResult._tag === 'stuck') {
1085
+ resultObj = {
1086
+ childSessionId,
1087
+ outcome: 'stuck',
1088
+ notes: childResult.message,
1089
+ ...(childResult.issueSummaries !== undefined
1090
+ ? { issueSummaries: childResult.issueSummaries }
1091
+ : {}),
1092
+ };
1093
+ }
1084
1094
  else {
1085
1095
  (0, assert_never_js_1.assertNever)(childResult);
1086
1096
  }
@@ -1093,6 +1103,31 @@ function makeSpawnAgentTool(sessionId, ctx, apiKey, thisWorkrailSessionId, curre
1093
1103
  },
1094
1104
  };
1095
1105
  }
1106
+ async function writeStuckOutboxEntry(opts) {
1107
+ try {
1108
+ const outboxPath = path.join(os.homedir(), '.workrail', 'outbox.jsonl');
1109
+ await fs.mkdir(path.dirname(outboxPath), { recursive: true });
1110
+ const entry = JSON.stringify({
1111
+ id: (0, node_crypto_1.randomUUID)(),
1112
+ kind: 'stuck',
1113
+ message: `Session stuck (${opts.reason}): workflowId=${opts.workflowId}` +
1114
+ (opts.issueSummaries && opts.issueSummaries.length > 0
1115
+ ? ` -- issues: ${opts.issueSummaries.join('; ')}`
1116
+ : ''),
1117
+ timestamp: new Date().toISOString(),
1118
+ workflowId: opts.workflowId,
1119
+ reason: opts.reason,
1120
+ ...(opts.issueSummaries && opts.issueSummaries.length > 0
1121
+ ? { issueSummaries: opts.issueSummaries }
1122
+ : {}),
1123
+ });
1124
+ await fs.appendFile(outboxPath, entry + '\n');
1125
+ }
1126
+ catch (err) {
1127
+ console.warn(`[WorkflowRunner] Could not write stuck outbox entry: ` +
1128
+ `${err instanceof Error ? err.message : String(err)}`);
1129
+ }
1130
+ }
1096
1131
  async function appendIssueAsync(issuesDir, sessionId, record) {
1097
1132
  await fs.mkdir(issuesDir, { recursive: true });
1098
1133
  const filePath = path.join(issuesDir, `${sessionId}.jsonl`);
@@ -1449,19 +1484,6 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1449
1484
  if (startContinueToken) {
1450
1485
  await persistTokens(sessionId, startContinueToken, startCheckpointToken);
1451
1486
  }
1452
- if (trigger.botIdentity) {
1453
- try {
1454
- await execFileAsync('git', ['-C', trigger.workspacePath, 'config', 'user.name', trigger.botIdentity.name]);
1455
- await execFileAsync('git', ['-C', trigger.workspacePath, 'config', 'user.email', trigger.botIdentity.email]);
1456
- console.log(`[WorkflowRunner] Bot identity set: sessionId=${sessionId} ` +
1457
- `name=${trigger.botIdentity.name} email=${trigger.botIdentity.email}`);
1458
- }
1459
- catch (identityErr) {
1460
- console.warn(`[WorkflowRunner] WARNING: Failed to set bot identity for sessionId=${sessionId}: ` +
1461
- `${identityErr instanceof Error ? identityErr.message : String(identityErr)}. ` +
1462
- `Commits will use default git config.`);
1463
- }
1464
- }
1465
1487
  let sessionWorkspacePath = trigger.workspacePath;
1466
1488
  let sessionWorktreePath;
1467
1489
  if (trigger.branchStrategy === 'worktree') {
@@ -1497,6 +1519,19 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1497
1519
  };
1498
1520
  }
1499
1521
  }
1522
+ if (trigger.botIdentity) {
1523
+ try {
1524
+ await execFileAsync('git', ['-C', sessionWorkspacePath, 'config', 'user.name', trigger.botIdentity.name]);
1525
+ await execFileAsync('git', ['-C', sessionWorkspacePath, 'config', 'user.email', trigger.botIdentity.email]);
1526
+ console.log(`[WorkflowRunner] Bot identity set: sessionId=${sessionId} ` +
1527
+ `name=${trigger.botIdentity.name} email=${trigger.botIdentity.email}`);
1528
+ }
1529
+ catch (identityErr) {
1530
+ console.warn(`[WorkflowRunner] WARNING: Failed to set bot identity for sessionId=${sessionId}: ` +
1531
+ `${identityErr instanceof Error ? identityErr.message : String(identityErr)}. ` +
1532
+ `Commits will use default git config.`);
1533
+ }
1534
+ }
1500
1535
  if (firstStep.isComplete) {
1501
1536
  await fs.unlink(path.join(exports.DAEMON_SESSIONS_DIR, `${sessionId}.json`)).catch(() => { });
1502
1537
  emitter?.emit({ kind: 'session_completed', sessionId, workflowId: trigger.workflowId, outcome: 'success', detail: 'stop', ...withWorkrailSession(workrailSessionId) });
@@ -1588,7 +1623,10 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1588
1623
  });
1589
1624
  const sessionTimeoutMs = (trigger.agentConfig?.maxSessionMinutes ?? DEFAULT_SESSION_TIMEOUT_MINUTES) * 60 * 1000;
1590
1625
  const maxTurns = trigger.agentConfig?.maxTurns ?? DEFAULT_MAX_TURNS;
1626
+ const sessionStartMs = Date.now();
1627
+ void sessionStartMs;
1591
1628
  let timeoutReason = null;
1629
+ let stuckReason = null;
1592
1630
  let turnCount = 0;
1593
1631
  const unsubscribe = agent.subscribe(async (event) => {
1594
1632
  if (event.type !== 'turn_end')
@@ -1623,6 +1661,17 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1623
1661
  argsSummary: lastNToolCalls[0]?.argsSummary,
1624
1662
  ...withWorkrailSession(workrailSessionId),
1625
1663
  });
1664
+ void writeStuckOutboxEntry({
1665
+ workflowId: trigger.workflowId,
1666
+ reason: 'repeated_tool_call',
1667
+ ...(issueSummaries.length > 0 ? { issueSummaries: [...issueSummaries] } : {}),
1668
+ });
1669
+ const stuckPolicy = trigger.agentConfig?.stuckAbortPolicy ?? 'abort';
1670
+ if (stuckPolicy !== 'notify_only' && stuckReason === null && timeoutReason === null) {
1671
+ stuckReason = 'repeated_tool_call';
1672
+ agent.abort();
1673
+ return;
1674
+ }
1626
1675
  }
1627
1676
  if (maxTurns > 0 &&
1628
1677
  turnCount >= Math.floor(maxTurns * 0.8) &&
@@ -1634,6 +1683,20 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1634
1683
  detail: `${turnCount} turns used, 0 step advances (${maxTurns} turn limit)`,
1635
1684
  ...withWorkrailSession(workrailSessionId),
1636
1685
  });
1686
+ const noProgressAbortEnabled = trigger.agentConfig?.noProgressAbortEnabled ?? false;
1687
+ if (noProgressAbortEnabled) {
1688
+ void writeStuckOutboxEntry({
1689
+ workflowId: trigger.workflowId,
1690
+ reason: 'no_progress',
1691
+ ...(issueSummaries.length > 0 ? { issueSummaries: [...issueSummaries] } : {}),
1692
+ });
1693
+ const noProgressPolicy = trigger.agentConfig?.stuckAbortPolicy ?? 'abort';
1694
+ if (noProgressPolicy !== 'notify_only' && stuckReason === null && timeoutReason === null) {
1695
+ stuckReason = 'no_progress';
1696
+ agent.abort();
1697
+ return;
1698
+ }
1699
+ }
1637
1700
  }
1638
1701
  if (timeoutReason !== null) {
1639
1702
  emitter?.emit({
@@ -1693,6 +1756,26 @@ async function runWorkflow(trigger, ctx, apiKey, daemonRegistry, emitter, steerR
1693
1756
  }
1694
1757
  console.log(`[WorkflowRunner] Agent loop ended: sessionId=${sessionId} stopReason=${stopReason}${errorMessage ? ` error=${errorMessage.slice(0, 120)}` : ''}`);
1695
1758
  }
1759
+ if (stuckReason !== null) {
1760
+ emitter?.emit({
1761
+ kind: 'session_completed',
1762
+ sessionId,
1763
+ workflowId: trigger.workflowId,
1764
+ outcome: 'timeout',
1765
+ detail: stuckReason,
1766
+ ...withWorkrailSession(workrailSessionId),
1767
+ });
1768
+ if (workrailSessionId !== null)
1769
+ daemonRegistry?.unregister(workrailSessionId, 'failed');
1770
+ return {
1771
+ _tag: 'stuck',
1772
+ workflowId: trigger.workflowId,
1773
+ reason: stuckReason,
1774
+ message: `Session aborted: stuck heuristic fired (${stuckReason})`,
1775
+ stopReason: 'aborted',
1776
+ ...(issueSummaries.length > 0 ? { issueSummaries: [...issueSummaries] } : {}),
1777
+ };
1778
+ }
1696
1779
  if (timeoutReason !== null) {
1697
1780
  emitter?.emit({ kind: 'session_completed', sessionId, workflowId: trigger.workflowId, outcome: 'timeout', detail: timeoutReason, ...withWorkrailSession(workrailSessionId) });
1698
1781
  if (workrailSessionId !== null)