github-issue-tower-defence-management 1.137.2 → 1.139.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 (50) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -2
  3. package/bin/adapter/entry-points/cli/index.js +2 -1
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +17 -3
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +5 -4
  8. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js +81 -0
  10. package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js.map +1 -0
  11. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +15 -0
  12. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -1
  13. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +30 -12
  14. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  15. package/bin/domain/usecases/StartPreparationUseCase.js +7 -1
  16. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.js +3 -0
  18. package/bin/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/adapter/entry-points/cli/index.ts +2 -0
  21. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +13 -0
  22. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  23. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +6 -0
  24. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +7 -4
  25. package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.test.ts +87 -0
  26. package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.ts +57 -0
  27. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +101 -0
  28. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +16 -0
  29. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +136 -6
  30. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +37 -12
  31. package/src/domain/usecases/StartPreparationUseCase.test.ts +54 -0
  32. package/src/domain/usecases/StartPreparationUseCase.ts +9 -0
  33. package/src/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.ts +6 -0
  34. package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +1 -0
  35. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  36. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  37. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
  38. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  39. package/types/adapter/repositories/FileSystemSubAgentLivenessResolver.d.ts +11 -0
  40. package/types/adapter/repositories/FileSystemSubAgentLivenessResolver.d.ts.map +1 -0
  41. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +1 -0
  42. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -1
  43. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +4 -2
  44. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  45. package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -1
  46. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  47. package/types/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.d.ts +7 -0
  48. package/types/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.d.ts.map +1 -0
  49. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +1 -0
  50. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -1
@@ -0,0 +1,57 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { SubAgentLivenessResolver } from '../../domain/usecases/adapter-interfaces/SubAgentLivenessResolver';
4
+
5
+ const RUNNING_SUBAGENTS_FILE_NAME = 'running-subagents.txt';
6
+
7
+ export class FileSystemSubAgentLivenessResolver implements SubAgentLivenessResolver {
8
+ constructor(private readonly runtimeRootDirectory: string | null) {}
9
+
10
+ resolveLiveSubAgentIds = async (params: {
11
+ sessionName: string;
12
+ mainTranscriptPath: string | null;
13
+ }): Promise<Set<string> | null> => {
14
+ const runningFilePath = this.resolveRunningSubAgentsFilePath(
15
+ params.mainTranscriptPath,
16
+ );
17
+ if (runningFilePath === null) {
18
+ return null;
19
+ }
20
+ let content: string;
21
+ try {
22
+ content = fs.readFileSync(runningFilePath, 'utf8');
23
+ } catch {
24
+ return null;
25
+ }
26
+ const liveIds = new Set<string>();
27
+ for (const line of content.split('\n')) {
28
+ const trimmed = line.trim();
29
+ if (trimmed.length > 0) {
30
+ liveIds.add(trimmed);
31
+ }
32
+ }
33
+ return liveIds;
34
+ };
35
+
36
+ private resolveRunningSubAgentsFilePath = (
37
+ mainTranscriptPath: string | null,
38
+ ): string | null => {
39
+ if (this.runtimeRootDirectory === null || mainTranscriptPath === null) {
40
+ return null;
41
+ }
42
+ const sessionUuid = path.basename(mainTranscriptPath, '.jsonl');
43
+ if (sessionUuid.length === 0 || sessionUuid.endsWith('.jsonl')) {
44
+ return null;
45
+ }
46
+ const cwdSlug = path.basename(path.dirname(mainTranscriptPath));
47
+ if (cwdSlug.length === 0 || cwdSlug === '.') {
48
+ return null;
49
+ }
50
+ return path.join(
51
+ this.runtimeRootDirectory,
52
+ cwdSlug,
53
+ sessionUuid,
54
+ RUNNING_SUBAGENTS_FILE_NAME,
55
+ );
56
+ };
57
+ }
@@ -127,4 +127,105 @@ describe('ProcTakeOwnershipSpawnRepository', () => {
127
127
 
128
128
  expect(repository.listSpawns()).toEqual([]);
129
129
  });
130
+
131
+ describe('listRunningIssueUrls', () => {
132
+ it('extracts issue URL from a bash-c style Take ownership spawn', () => {
133
+ writeProcess({
134
+ pid: 401,
135
+ cmdline: argv(
136
+ 'bash',
137
+ '-c',
138
+ `claude-agent -p "Take ownership of ${issueUrl}" | tee /home/user/logs-aw/20260626_120000_abc.log`,
139
+ ),
140
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a' },
141
+ });
142
+
143
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
144
+
145
+ expect(repository.listRunningIssueUrls()).toEqual([issueUrl]);
146
+ });
147
+
148
+ it('extracts issue URL from a null-separated claude-agent spawn', () => {
149
+ writeProcess({
150
+ pid: 402,
151
+ cmdline: argv(
152
+ 'timeout',
153
+ '--kill-after=60s',
154
+ '3h',
155
+ 'claude-agent',
156
+ '--agent',
157
+ 'impl',
158
+ '-p',
159
+ `Take ownership of ${issueUrl} and finish it`,
160
+ '--model',
161
+ 'claude-sonnet-4-6',
162
+ ),
163
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b' },
164
+ });
165
+
166
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
167
+
168
+ expect(repository.listRunningIssueUrls()).toEqual([issueUrl]);
169
+ });
170
+
171
+ it('returns empty list when no Take ownership process is running', () => {
172
+ writeProcess({
173
+ pid: 403,
174
+ cmdline: argv('claude', '--name', issueUrl),
175
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-c' },
176
+ });
177
+
178
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
179
+
180
+ expect(repository.listRunningIssueUrls()).toEqual([]);
181
+ });
182
+
183
+ it('returns URLs from all matching processes', () => {
184
+ const issueUrl2 = 'https://github.com/HiromiShikata/example/issues/2';
185
+ writeProcess({
186
+ pid: 404,
187
+ cmdline: argv(
188
+ 'timeout',
189
+ '--kill-after=60s',
190
+ '3h',
191
+ 'claude-agent',
192
+ '--agent',
193
+ 'impl',
194
+ '-p',
195
+ `Take ownership of ${issueUrl} and finish it`,
196
+ '--model',
197
+ 'claude-sonnet-4-6',
198
+ ),
199
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
200
+ });
201
+ writeProcess({
202
+ pid: 405,
203
+ cmdline: argv(
204
+ 'timeout',
205
+ '--kill-after=60s',
206
+ '3h',
207
+ 'claude-agent',
208
+ '--agent',
209
+ 'impl',
210
+ '-p',
211
+ `Take ownership of ${issueUrl2} and finish it`,
212
+ '--model',
213
+ 'claude-sonnet-4-6',
214
+ ),
215
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-e' },
216
+ });
217
+
218
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
219
+
220
+ expect(repository.listRunningIssueUrls()).toEqual([issueUrl, issueUrl2]);
221
+ });
222
+
223
+ it('returns empty list when the proc directory does not exist', () => {
224
+ const repository = new ProcTakeOwnershipSpawnRepository(
225
+ path.join(procDirectory, 'missing'),
226
+ );
227
+
228
+ expect(repository.listRunningIssueUrls()).toEqual([]);
229
+ });
230
+ });
130
231
  });
@@ -9,6 +9,7 @@ const DEFAULT_PROC_DIRECTORY = '/proc';
9
9
  const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
10
10
  const TAKE_OWNERSHIP_MARKER = 'Take ownership';
11
11
  const LOG_PATH_PATTERN = /\/logs-aw\/[^\0"]+\.log/;
12
+ const ISSUE_URL_PATTERN = /Take ownership of (https:\/\/github\.com\/[^ \0"]+)/;
12
13
 
13
14
  const parseCommandLineArguments = (cmdline: string): string[] =>
14
15
  cmdline.split('\0').filter((argument) => argument.length > 0);
@@ -37,6 +38,21 @@ export class ProcTakeOwnershipSpawnRepository implements TakeOwnershipSpawnRepos
37
38
  return spawns;
38
39
  };
39
40
 
41
+ listRunningIssueUrls = (): string[] => {
42
+ const urls: string[] = [];
43
+ for (const pidDirectory of this.listProcessIdDirectories()) {
44
+ const rawCmdline = this.readRawCmdline(pidDirectory);
45
+ if (rawCmdline === null) {
46
+ continue;
47
+ }
48
+ const match = rawCmdline.match(ISSUE_URL_PATTERN);
49
+ if (match !== null) {
50
+ urls.push(match[1]);
51
+ }
52
+ }
53
+ return urls;
54
+ };
55
+
40
56
  private listProcessIdDirectories = (): string[] => {
41
57
  let entries: string[];
42
58
  try {
@@ -6,6 +6,7 @@ import {
6
6
  SubAgentProcess,
7
7
  SubAgentProcessLister,
8
8
  } from '../../domain/usecases/adapter-interfaces/SubAgentProcessLister';
9
+ import { SubAgentLivenessResolver } from '../../domain/usecases/adapter-interfaces/SubAgentLivenessResolver';
9
10
  import { FileSystemSubAgentTranscriptDirectoryResolver } from './FileSystemSubAgentTranscriptDirectoryResolver';
10
11
  import { TranscriptSessionSubAgentActivityRepository } from './TranscriptSessionSubAgentActivityRepository';
11
12
 
@@ -1085,6 +1086,23 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1085
1086
  content: `<task-notification>\n<task-id>${agentId}</task-id>\n<status>completed</status>\n</task-notification>`,
1086
1087
  });
1087
1088
 
1089
+ const stoppedNotificationEntry = (
1090
+ agentId: string,
1091
+ timestamp: string,
1092
+ ): object => ({
1093
+ type: 'queue-operation',
1094
+ operation: 'enqueue',
1095
+ timestamp,
1096
+ content: `<task-notification>\n<task-id>${agentId}</task-id>\n<status>stopped</status>\n</task-notification>`,
1097
+ });
1098
+
1099
+ const livenessResolverWith = (
1100
+ liveIds: string[] | null,
1101
+ ): SubAgentLivenessResolver => ({
1102
+ resolveLiveSubAgentIds: async () =>
1103
+ liveIds === null ? null : new Set(liveIds),
1104
+ });
1105
+
1088
1106
  it('excludes a sub-agent killed via TaskStop when the parent transcript records a killed notification for its id', async () => {
1089
1107
  const sessionName = 'https_//github_com/owner/repo/issues/9';
1090
1108
  const killedAgentId = 'aaabbbbcccc00001';
@@ -1137,7 +1155,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1137
1155
  expect(result.size).toBe(0);
1138
1156
  });
1139
1157
 
1140
- it('does not exclude a sub-agent whose parent transcript records only a completed notification for its id', async () => {
1158
+ it('excludes a sub-agent whose parent transcript records a completed notification for its id', async () => {
1141
1159
  const sessionName = 'https_//github_com/owner/repo/issues/9';
1142
1160
  const startTimestamp = '2026-06-27T11:45:00.000Z';
1143
1161
  const completedAgentId = 'aaabbbbcccc00003';
@@ -1161,14 +1179,71 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1161
1179
  transcriptMapFor([sessionName]),
1162
1180
  );
1163
1181
 
1164
- expect(result.get(sessionName)).toEqual([
1182
+ expect(result.size).toBe(0);
1183
+ });
1184
+
1185
+ it('excludes a sub-agent whose parent transcript records a stopped notification for its id', async () => {
1186
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1187
+ const stoppedAgentId = 'aaabbbbcccc00006';
1188
+ writeAgentTranscript(
1189
+ sessionName,
1190
+ stoppedAgentId,
1191
+ pendingToolUseEntries('2026-06-27T11:00:00.000Z'),
1192
+ nowEpochSeconds - 300,
1193
+ );
1194
+ writeMainTranscript(sessionName, [
1195
+ stoppedNotificationEntry(stoppedAgentId, '2026-06-27T11:55:00.000Z'),
1196
+ ]);
1197
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1198
+ createResolver(),
1199
+ emptyProcessLister(),
1200
+ now,
1201
+ );
1202
+
1203
+ const result = await repository.listSubAgentActivitiesBySessionName(
1204
+ [sessionName],
1205
+ transcriptMapFor([sessionName]),
1206
+ );
1207
+
1208
+ expect(result.size).toBe(0);
1209
+ });
1210
+
1211
+ it('excludes every task id listed in a single multi-id terminal notification', async () => {
1212
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1213
+ const firstAgentId = 'aaabbbbcccc10001';
1214
+ const secondAgentId = 'aaabbbbcccc10002';
1215
+ writeAgentTranscript(
1216
+ sessionName,
1217
+ firstAgentId,
1218
+ pendingToolUseEntries('2026-06-27T11:00:00.000Z'),
1219
+ nowEpochSeconds - 300,
1220
+ );
1221
+ writeAgentTranscript(
1222
+ sessionName,
1223
+ secondAgentId,
1224
+ pendingToolUseEntries('2026-06-27T11:00:00.000Z'),
1225
+ nowEpochSeconds - 300,
1226
+ );
1227
+ writeMainTranscript(sessionName, [
1165
1228
  {
1166
- label: `agent-${completedAgentId}`,
1167
- silentSeconds: 120,
1168
- runningSeconds: 900,
1169
- waitingOnExternalProcess: false,
1229
+ type: 'queue-operation',
1230
+ operation: 'enqueue',
1231
+ timestamp: '2026-06-27T11:55:00.000Z',
1232
+ content: `<task-notification>\n<task-id>${firstAgentId}</task-id>\n<task-id>${secondAgentId}</task-id>\n<status>completed</status>\n</task-notification>`,
1170
1233
  },
1171
1234
  ]);
1235
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1236
+ createResolver(),
1237
+ emptyProcessLister(),
1238
+ now,
1239
+ );
1240
+
1241
+ const result = await repository.listSubAgentActivitiesBySessionName(
1242
+ [sessionName],
1243
+ transcriptMapFor([sessionName]),
1244
+ );
1245
+
1246
+ expect(result.size).toBe(0);
1172
1247
  });
1173
1248
 
1174
1249
  it('excludes a killed sub-agent but reports a still-active sibling in the same session', async () => {
@@ -1211,6 +1286,61 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1211
1286
  ]);
1212
1287
  });
1213
1288
 
1289
+ it('excludes a stale sub-agent whose tail is a plain tool result when no live process exists for its id', async () => {
1290
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1291
+ const deadAgentId = 'aaabbbbcccc20001';
1292
+ writeAgentTranscript(
1293
+ sessionName,
1294
+ deadAgentId,
1295
+ inFlightToolResultTailEntries('2026-06-27T11:00:00.000Z'),
1296
+ nowEpochSeconds - 3600,
1297
+ );
1298
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1299
+ createResolver(),
1300
+ emptyProcessLister(),
1301
+ now,
1302
+ livenessResolverWith(['aaabbbbcccc20002']),
1303
+ );
1304
+
1305
+ const result = await repository.listSubAgentActivitiesBySessionName(
1306
+ [sessionName],
1307
+ transcriptMapFor([sessionName]),
1308
+ );
1309
+
1310
+ expect(result.size).toBe(0);
1311
+ });
1312
+
1313
+ it('flags a genuinely-running silent sub-agent whose id is present in the live process set', async () => {
1314
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1315
+ const liveAgentId = 'aaabbbbcccc20003';
1316
+ writeAgentTranscript(
1317
+ sessionName,
1318
+ liveAgentId,
1319
+ pendingToolUseEntries('2026-06-27T11:45:00.000Z'),
1320
+ nowEpochSeconds - 600,
1321
+ );
1322
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1323
+ createResolver(),
1324
+ emptyProcessLister(),
1325
+ now,
1326
+ livenessResolverWith([liveAgentId]),
1327
+ );
1328
+
1329
+ const result = await repository.listSubAgentActivitiesBySessionName(
1330
+ [sessionName],
1331
+ transcriptMapFor([sessionName]),
1332
+ );
1333
+
1334
+ expect(result.get(sessionName)).toEqual([
1335
+ {
1336
+ label: `agent-${liveAgentId}`,
1337
+ silentSeconds: 600,
1338
+ runningSeconds: 900,
1339
+ waitingOnExternalProcess: false,
1340
+ },
1341
+ ]);
1342
+ });
1343
+
1214
1344
  it('reports a sub-agent even when the parent transcript path does not exist', async () => {
1215
1345
  const sessionName = 'https_//github_com/owner/repo/issues/9';
1216
1346
  const startTimestamp = '2026-06-27T11:45:00.000Z';
@@ -2,9 +2,14 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnapshot';
4
4
  import { SessionSubAgentActivityRepository } from '../../domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository';
5
+ import { SubAgentLivenessResolver } from '../../domain/usecases/adapter-interfaces/SubAgentLivenessResolver';
5
6
  import { SubAgentProcessLister } from '../../domain/usecases/adapter-interfaces/SubAgentProcessLister';
6
7
  import { SubAgentTranscriptDirectoryResolver } from '../../domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver';
7
8
 
9
+ const alwaysIndeterminateLivenessResolver: SubAgentLivenessResolver = {
10
+ resolveLiveSubAgentIds: async () => null,
11
+ };
12
+
8
13
  const isRecord = (value: unknown): value is Record<string, unknown> =>
9
14
  typeof value === 'object' && value !== null;
10
15
 
@@ -190,7 +195,14 @@ const parseTranscript = (content: string): ParsedTranscript => {
190
195
 
191
196
  const clampToZero = (value: number): number => (value > 0 ? value : 0);
192
197
 
193
- const parseKilledOrFailedAgentIds = (content: string): Set<string> => {
198
+ const TERMINAL_TASK_NOTIFICATION_STATUSES = new Set([
199
+ 'completed',
200
+ 'killed',
201
+ 'failed',
202
+ 'stopped',
203
+ ]);
204
+
205
+ const parseTerminalAgentIds = (content: string): Set<string> => {
194
206
  const result = new Set<string>();
195
207
  for (const line of content.split('\n')) {
196
208
  const trimmed = line.trim();
@@ -216,13 +228,16 @@ const parseKilledOrFailedAgentIds = (content: string): Set<string> => {
216
228
  if (notifContent === null) {
217
229
  continue;
218
230
  }
219
- const taskIdMatch = notifContent.match(/<task-id>(a[0-9a-f]+)<\/task-id>/);
220
231
  const statusMatch = notifContent.match(/<status>([^<]+)<\/status>/);
221
- if (!taskIdMatch || !statusMatch) {
232
+ if (statusMatch === null) {
222
233
  continue;
223
234
  }
224
- const status = statusMatch[1];
225
- if (status === 'killed' || status === 'failed') {
235
+ if (!TERMINAL_TASK_NOTIFICATION_STATUSES.has(statusMatch[1])) {
236
+ continue;
237
+ }
238
+ for (const taskIdMatch of notifContent.matchAll(
239
+ /<task-id>(a[0-9a-f]+)<\/task-id>/g,
240
+ )) {
226
241
  result.add(taskIdMatch[1]);
227
242
  }
228
243
  }
@@ -234,6 +249,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
234
249
  private readonly directoryResolver: SubAgentTranscriptDirectoryResolver,
235
250
  private readonly processLister: SubAgentProcessLister,
236
251
  private readonly now: Date,
252
+ private readonly livenessResolver: SubAgentLivenessResolver = alwaysIndeterminateLivenessResolver,
237
253
  ) {}
238
254
 
239
255
  listSubAgentActivitiesBySessionName = async (
@@ -262,12 +278,17 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
262
278
  if (directory === null) {
263
279
  continue;
264
280
  }
265
- const killedOrFailedAgentIds =
266
- this.loadKilledOrFailedAgentIds(mainTranscriptPath);
281
+ const terminalAgentIds = this.loadTerminalAgentIds(mainTranscriptPath);
282
+ const liveSubAgentIds =
283
+ await this.livenessResolver.resolveLiveSubAgentIds({
284
+ sessionName,
285
+ mainTranscriptPath,
286
+ });
267
287
  const activities = await this.collectActivities(
268
288
  directory,
269
289
  nowEpochSeconds,
270
- killedOrFailedAgentIds,
290
+ terminalAgentIds,
291
+ liveSubAgentIds,
271
292
  loadNormalizedProcessCommandLines,
272
293
  );
273
294
  if (activities.length > 0) {
@@ -277,7 +298,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
277
298
  return result;
278
299
  };
279
300
 
280
- private loadKilledOrFailedAgentIds = (
301
+ private loadTerminalAgentIds = (
281
302
  mainTranscriptPath: string | null,
282
303
  ): Set<string> => {
283
304
  if (mainTranscriptPath === null) {
@@ -289,13 +310,14 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
289
310
  } catch {
290
311
  return new Set();
291
312
  }
292
- return parseKilledOrFailedAgentIds(content);
313
+ return parseTerminalAgentIds(content);
293
314
  };
294
315
 
295
316
  private collectActivities = async (
296
317
  directory: string,
297
318
  nowEpochSeconds: number,
298
- killedOrFailedAgentIds: Set<string>,
319
+ terminalAgentIds: Set<string>,
320
+ liveSubAgentIds: Set<string> | null,
299
321
  loadNormalizedProcessCommandLines: () => Promise<string[]>,
300
322
  ): Promise<SubAgentActivity[]> => {
301
323
  let entries: fs.Dirent[];
@@ -311,7 +333,10 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
311
333
  continue;
312
334
  }
313
335
  const agentId = fileName.slice('agent-'.length, -'.jsonl'.length);
314
- if (killedOrFailedAgentIds.has(agentId)) {
336
+ if (terminalAgentIds.has(agentId)) {
337
+ continue;
338
+ }
339
+ if (liveSubAgentIds !== null && !liveSubAgentIds.has(agentId)) {
315
340
  continue;
316
341
  }
317
342
  const filePath = path.join(directory, fileName);
@@ -6,6 +6,7 @@ import {
6
6
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
7
7
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
8
8
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
9
+ import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
9
10
  import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
10
11
  import { Issue } from '../entities/Issue';
11
12
  import { Project } from '../entities/Project';
@@ -95,6 +96,7 @@ describe('StartPreparationUseCase', () => {
95
96
  >;
96
97
  let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
97
98
  let mockClaudeTokenUsageRepository: Mocked<ClaudeTokenUsageRepository>;
99
+ let mockTakeOwnershipSpawnRepository: Mocked<TakeOwnershipSpawnRepository>;
98
100
  let mockProject: Project;
99
101
  beforeEach(() => {
100
102
  jest.resetAllMocks();
@@ -121,11 +123,16 @@ describe('StartPreparationUseCase', () => {
121
123
  getTokenInFlightCounts: jest.fn().mockResolvedValue({}),
122
124
  proxyBaseUrl: jest.fn().mockReturnValue('http://127.0.0.1:8787'),
123
125
  };
126
+ mockTakeOwnershipSpawnRepository = {
127
+ listSpawns: jest.fn().mockReturnValue([]),
128
+ listRunningIssueUrls: jest.fn().mockReturnValue([]),
129
+ };
124
130
  useCase = new StartPreparationUseCase(
125
131
  mockProjectRepository,
126
132
  mockIssueRepository,
127
133
  mockLocalCommandRunner,
128
134
  mockClaudeTokenUsageRepository,
135
+ mockTakeOwnershipSpawnRepository,
129
136
  );
130
137
  });
131
138
  it('should run aw command for awaiting workspace issues', async () => {
@@ -181,6 +188,45 @@ describe('StartPreparationUseCase', () => {
181
188
  ],
182
189
  ]);
183
190
  });
191
+
192
+ it('skips an issue whose URL is already in the running worker list', async () => {
193
+ const runningUrl = 'https://github.com/user/repo/issues/1';
194
+ const awaitingIssues: Issue[] = [
195
+ createMockIssue({
196
+ url: runningUrl,
197
+ title: 'Issue 1',
198
+ labels: ['category:impl'],
199
+ status: 'Awaiting Workspace',
200
+ author: 'testuser',
201
+ }),
202
+ ];
203
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
204
+ mockIssueRepository.getStoryObjectMap.mockResolvedValue(
205
+ createMockStoryObjectMap(awaitingIssues),
206
+ );
207
+ mockTakeOwnershipSpawnRepository.listRunningIssueUrls.mockReturnValue([
208
+ runningUrl,
209
+ ]);
210
+
211
+ await useCase.run({
212
+ projectUrl: 'https://github.com/user/repo',
213
+ defaultAgentName: 'agent1',
214
+ defaultLlmModelName: 'claude-opus',
215
+ fallbackLlmModelName: null,
216
+ defaultLlmAgentName: null,
217
+ configFilePath: '/path/to/config.yml',
218
+ maximumPreparingIssuesCount: null,
219
+ utilizationPercentageThreshold: 90,
220
+ allowedIssueAuthors: ['testuser'],
221
+ manager: 'manager-user',
222
+ codexHomeCandidates: null,
223
+ labelsAsLlmAgentName: null,
224
+ });
225
+
226
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
227
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
228
+ });
229
+
184
230
  it('should pass --branch to aw command when issue has an existing linked PR', async () => {
185
231
  const awaitingIssues: Issue[] = [
186
232
  createMockIssue({
@@ -6275,6 +6321,10 @@ describe('StartPreparationUseCase.buildRotationOrder', () => {
6275
6321
  mockIssueRepositoryForRotation,
6276
6322
  mockLocalCommandRunnerForRotation,
6277
6323
  mockClaudeTokenUsageRepositoryForRotation,
6324
+ {
6325
+ listSpawns: jest.fn().mockReturnValue([]),
6326
+ listRunningIssueUrls: jest.fn().mockReturnValue([]),
6327
+ },
6278
6328
  );
6279
6329
 
6280
6330
  it('lists selected tokens first in ascending 7-day reset deadline order then excluded tokens', () => {
@@ -6564,6 +6614,10 @@ describe('StartPreparationUseCase.getTokenConcurrentLimit', () => {
6564
6614
  getTokenInFlightCounts: jest.fn(),
6565
6615
  proxyBaseUrl: jest.fn(),
6566
6616
  },
6617
+ {
6618
+ listSpawns: jest.fn().mockReturnValue([]),
6619
+ listRunningIssueUrls: jest.fn().mockReturnValue([]),
6620
+ },
6567
6621
  );
6568
6622
  });
6569
6623
 
@@ -2,6 +2,7 @@ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
2
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
3
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
4
4
  import { ClaudeTokenUsageRepository } from './adapter-interfaces/ClaudeTokenUsageRepository';
5
+ import { TakeOwnershipSpawnRepository } from './adapter-interfaces/TakeOwnershipSpawnRepository';
5
6
  import { ClaudeTokenUsage } from '../entities/ClaudeTokenUsage';
6
7
  import {
7
8
  AWAITING_WORKSPACE_STATUS_NAME,
@@ -38,6 +39,7 @@ export class StartPreparationUseCase {
38
39
  >,
39
40
  private readonly localCommandRunner: LocalCommandRunner,
40
41
  private readonly claudeTokenUsageRepository: ClaudeTokenUsageRepository,
42
+ private readonly takeOwnershipSpawnRepository: TakeOwnershipSpawnRepository,
41
43
  ) {}
42
44
 
43
45
  private weeklyLimitTypeForModel = (modelName: string | null): string => {
@@ -369,6 +371,9 @@ export class StartPreparationUseCase {
369
371
  return { rotationOrder };
370
372
  }
371
373
 
374
+ const runningIssueUrls = new Set(
375
+ this.takeOwnershipSpawnRepository.listRunningIssueUrls(),
376
+ );
372
377
  const awaitingWorkspaceIssues = allOpenedIssues
373
378
  .filter(
374
379
  (issue) =>
@@ -428,6 +433,10 @@ export class StartPreparationUseCase {
428
433
  exclusionCounts.dependedIssueUrls++;
429
434
  continue;
430
435
  }
436
+ if (runningIssueUrls.has(issue.url)) {
437
+ console.warn(`Skipping ${issue.url}: worker already running.`);
438
+ continue;
439
+ }
431
440
  if (
432
441
  issue.nextActionDate !== null &&
433
442
  issue.nextActionDate >= tomorrowStart
@@ -0,0 +1,6 @@
1
+ export interface SubAgentLivenessResolver {
2
+ resolveLiveSubAgentIds: (params: {
3
+ sessionName: string;
4
+ mainTranscriptPath: string | null;
5
+ }) => Promise<Set<string> | null>;
6
+ }
@@ -5,4 +5,5 @@ export type TakeOwnershipSpawn = {
5
5
 
6
6
  export interface TakeOwnershipSpawnRepository {
7
7
  listSpawns: () => TakeOwnershipSpawn[];
8
+ listRunningIssueUrls: () => string[];
8
9
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EACL,UAAU,EACV,cAAc,EACd,wBAAwB,EACxB,YAAY,EACZ,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AAsJzB,eAAO,MAAM,OAAO,SAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/cli/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EACL,UAAU,EACV,cAAc,EACd,wBAAwB,EACxB,YAAY,EACZ,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AAuJzB,eAAO,MAAM,OAAO,SAAgB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAiD3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,EACjB,6BAA4B,MAAM,EAAE,GAAG,IAAW,KACjD,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAwlBP;CACH"}
1
+ {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAoCA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAkD3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,EACjB,6BAA4B,MAAM,EAAE,GAAG,IAAW,KACjD,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAkmBP;CACH"}
@@ -10,6 +10,7 @@ export type NotifySilentTmuxSessionsParams = {
10
10
  subAgentOutputRootDirectory: string | null;
11
11
  subAgentProcessMatchPattern: string | null;
12
12
  subAgentTranscriptRootDirectory: string | null;
13
+ subAgentRuntimeRootDirectory: string | null;
13
14
  mainSilentThresholdSeconds: number;
14
15
  unansweredOwnerCallGraceSeconds: number;
15
16
  subAgentSilentThresholdSeconds: number;
@@ -1 +1 @@
1
- {"version":3,"file":"notifySilentTmuxSessions.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,gEAAgE,CAAC;AAGpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kEAAkE,CAAC;AACxG,OAAO,EAEL,qBAAqB,EAQtB,MAAM,0DAA0D,CAAC;AAelE,OAAO,EAEL,6BAA6B,EAC9B,MAAM,6DAA6D,CAAC;AAKrE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,0BAA0B,EAAE,MAAM,CAAC;IACnC,+BAA+B,EAAE,MAAM,CAAC;IACxC,8BAA8B,EAAE,MAAM,CAAC;IACvC,+BAA+B,EAAE,MAAM,CAAC;IACxC,cAAc,EAAE,MAAM,CAAC;IACvB,qCAAqC,EAAE,MAAM,CAAC;IAC9C,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,qBAAqB,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpD,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,CAAC;IACrC,gBAAgB,EAAE,6BAA6B,CAAC;IAChD,GAAG,EAAE,IAAI,CAAC;CACX,CAAC;AAqCF,eAAO,MAAM,wBAAwB,GACnC,QAAQ,8BAA8B,KACrC,OAAO,CAAC,IAAI,CA2Ed,CAAC;AAEF,eAAO,MAAM,0CAA0C;;;;;;;;CAS7C,CAAC"}
1
+ {"version":3,"file":"notifySilentTmuxSessions.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,gEAAgE,CAAC;AAGpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kEAAkE,CAAC;AACxG,OAAO,EAEL,qBAAqB,EAQtB,MAAM,0DAA0D,CAAC;AAgBlE,OAAO,EAEL,6BAA6B,EAC9B,MAAM,6DAA6D,CAAC;AAKrE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,0BAA0B,EAAE,MAAM,CAAC;IACnC,+BAA+B,EAAE,MAAM,CAAC;IACxC,8BAA8B,EAAE,MAAM,CAAC;IACvC,+BAA+B,EAAE,MAAM,CAAC;IACxC,cAAc,EAAE,MAAM,CAAC;IACvB,qCAAqC,EAAE,MAAM,CAAC;IAC9C,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,qBAAqB,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpD,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,4BAA4B,EAAE,MAAM,CAAC;IACrC,gBAAgB,EAAE,6BAA6B,CAAC;IAChD,GAAG,EAAE,IAAI,CAAC;CACX,CAAC;AAuCF,eAAO,MAAM,wBAAwB,GACnC,QAAQ,8BAA8B,KACrC,OAAO,CAAC,IAAI,CA6Ed,CAAC;AAEF,eAAO,MAAM,0CAA0C;;;;;;;;CAS7C,CAAC"}