pm-orchestrator-runner 1.0.14 → 1.0.16

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.
@@ -1,19 +1,33 @@
1
1
  "use strict";
2
2
  /**
3
- * Queue Store - DynamoDB Local implementation
3
+ * Queue Store - DynamoDB Local implementation (v2)
4
4
  * Per spec/20_QUEUE_STORE.md
5
5
  *
6
+ * v2 Changes:
7
+ * - Single fixed table: pm-runner-queue
8
+ * - Composite key: PK=namespace, SK=task_id
9
+ * - GSI: status-index (status + created_at)
10
+ * - Namespace-based separation in single table
11
+ *
6
12
  * Provides queue operations with:
7
13
  * - Atomic QUEUED -> RUNNING transitions (conditional update)
8
14
  * - Double execution prevention
9
15
  * - Fail-closed error handling
10
16
  */
11
17
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.QueueStore = exports.VALID_STATUS_TRANSITIONS = void 0;
18
+ exports.QueueStore = exports.VALID_STATUS_TRANSITIONS = exports.RUNNERS_TABLE_NAME = exports.QUEUE_TABLE_NAME = void 0;
13
19
  exports.isValidStatusTransition = isValidStatusTransition;
14
20
  const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
15
21
  const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
16
22
  const uuid_1 = require("uuid");
23
+ /**
24
+ * Fixed table name (v2: single table for all namespaces)
25
+ */
26
+ exports.QUEUE_TABLE_NAME = 'pm-runner-queue';
27
+ /**
28
+ * Runners table name (v2: heartbeat tracking)
29
+ */
30
+ exports.RUNNERS_TABLE_NAME = 'pm-runner-runners';
17
31
  /**
18
32
  * Valid status transitions
19
33
  * Per spec/20_QUEUE_STORE.md
@@ -32,17 +46,18 @@ function isValidStatusTransition(fromStatus, toStatus) {
32
46
  return exports.VALID_STATUS_TRANSITIONS[fromStatus].includes(toStatus);
33
47
  }
34
48
  /**
35
- * Queue Store
49
+ * Queue Store (v2)
36
50
  * Manages task queue with DynamoDB Local
51
+ * Single table design with namespace-based separation
37
52
  */
38
53
  class QueueStore {
39
54
  client;
40
55
  docClient;
41
- tableName;
56
+ namespace;
42
57
  endpoint;
43
- constructor(config = {}) {
58
+ constructor(config) {
44
59
  this.endpoint = config.endpoint || 'http://localhost:8000';
45
- this.tableName = config.tableName || 'pm-runner-queue';
60
+ this.namespace = config.namespace;
46
61
  const region = config.region || 'local';
47
62
  this.client = new client_dynamodb_1.DynamoDBClient({
48
63
  endpoint: this.endpoint,
@@ -59,10 +74,16 @@ class QueueStore {
59
74
  });
60
75
  }
61
76
  /**
62
- * Get table name
77
+ * Get fixed table name (v2: always returns pm-runner-queue)
63
78
  */
64
79
  getTableName() {
65
- return this.tableName;
80
+ return exports.QUEUE_TABLE_NAME;
81
+ }
82
+ /**
83
+ * Get namespace
84
+ */
85
+ getNamespace() {
86
+ return this.namespace;
66
87
  }
67
88
  /**
68
89
  * Get endpoint
@@ -75,7 +96,7 @@ class QueueStore {
75
96
  */
76
97
  async tableExists() {
77
98
  try {
78
- await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: this.tableName }));
99
+ await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: exports.QUEUE_TABLE_NAME }));
79
100
  return true;
80
101
  }
81
102
  catch (error) {
@@ -86,33 +107,39 @@ class QueueStore {
86
107
  }
87
108
  }
88
109
  /**
89
- * Create table with required GSIs
90
- * Per spec/20_QUEUE_STORE.md table definition
110
+ * Delete queue table (for testing)
111
+ */
112
+ async deleteTable() {
113
+ try {
114
+ await this.client.send(new client_dynamodb_1.DeleteTableCommand({ TableName: exports.QUEUE_TABLE_NAME }));
115
+ }
116
+ catch (error) {
117
+ if (error instanceof client_dynamodb_1.ResourceNotFoundException) {
118
+ // Table doesn't exist, ignore
119
+ return;
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ /**
125
+ * Create queue table with composite key (v2)
126
+ * PK: namespace, SK: task_id
91
127
  */
92
128
  async createTable() {
93
129
  const command = new client_dynamodb_1.CreateTableCommand({
94
- TableName: this.tableName,
95
- KeySchema: [{ AttributeName: 'task_id', KeyType: 'HASH' }],
130
+ TableName: exports.QUEUE_TABLE_NAME,
131
+ KeySchema: [
132
+ { AttributeName: 'namespace', KeyType: 'HASH' },
133
+ { AttributeName: 'task_id', KeyType: 'RANGE' },
134
+ ],
96
135
  AttributeDefinitions: [
136
+ { AttributeName: 'namespace', AttributeType: 'S' },
97
137
  { AttributeName: 'task_id', AttributeType: 'S' },
98
- { AttributeName: 'session_id', AttributeType: 'S' },
99
138
  { AttributeName: 'status', AttributeType: 'S' },
100
139
  { AttributeName: 'created_at', AttributeType: 'S' },
101
140
  { AttributeName: 'task_group_id', AttributeType: 'S' },
102
141
  ],
103
142
  GlobalSecondaryIndexes: [
104
- {
105
- IndexName: 'session-index',
106
- KeySchema: [
107
- { AttributeName: 'session_id', KeyType: 'HASH' },
108
- { AttributeName: 'created_at', KeyType: 'RANGE' },
109
- ],
110
- Projection: { ProjectionType: 'ALL' },
111
- ProvisionedThroughput: {
112
- ReadCapacityUnits: 5,
113
- WriteCapacityUnits: 5,
114
- },
115
- },
116
143
  {
117
144
  IndexName: 'status-index',
118
145
  KeySchema: [
@@ -146,24 +173,67 @@ class QueueStore {
146
173
  await this.client.send(command);
147
174
  }
148
175
  /**
149
- * Ensure table exists, create if not
176
+ * Create runners table (v2)
177
+ * PK: namespace, SK: runner_id
178
+ */
179
+ async createRunnersTable() {
180
+ const command = new client_dynamodb_1.CreateTableCommand({
181
+ TableName: exports.RUNNERS_TABLE_NAME,
182
+ KeySchema: [
183
+ { AttributeName: 'namespace', KeyType: 'HASH' },
184
+ { AttributeName: 'runner_id', KeyType: 'RANGE' },
185
+ ],
186
+ AttributeDefinitions: [
187
+ { AttributeName: 'namespace', AttributeType: 'S' },
188
+ { AttributeName: 'runner_id', AttributeType: 'S' },
189
+ ],
190
+ ProvisionedThroughput: {
191
+ ReadCapacityUnits: 5,
192
+ WriteCapacityUnits: 5,
193
+ },
194
+ });
195
+ await this.client.send(command);
196
+ }
197
+ /**
198
+ * Check if runners table exists
199
+ */
200
+ async runnersTableExists() {
201
+ try {
202
+ await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: exports.RUNNERS_TABLE_NAME }));
203
+ return true;
204
+ }
205
+ catch (error) {
206
+ if (error instanceof client_dynamodb_1.ResourceNotFoundException) {
207
+ return false;
208
+ }
209
+ throw error;
210
+ }
211
+ }
212
+ /**
213
+ * Ensure both tables exist
150
214
  */
151
215
  async ensureTable() {
152
- const exists = await this.tableExists();
153
- if (!exists) {
216
+ // Queue table
217
+ const queueExists = await this.tableExists();
218
+ if (!queueExists) {
154
219
  await this.createTable();
155
- // Wait for table to be active
156
- await this.waitForTableActive();
220
+ await this.waitForTableActive(exports.QUEUE_TABLE_NAME);
221
+ }
222
+ // Runners table
223
+ const runnersExists = await this.runnersTableExists();
224
+ if (!runnersExists) {
225
+ await this.createRunnersTable();
226
+ await this.waitForTableActive(exports.RUNNERS_TABLE_NAME);
157
227
  }
158
228
  }
159
229
  /**
160
230
  * Wait for table to become active
161
231
  */
162
- async waitForTableActive(maxWaitMs = 30000) {
232
+ async waitForTableActive(tableName, maxWaitMs = 30000) {
163
233
  const startTime = Date.now();
164
234
  while (Date.now() - startTime < maxWaitMs) {
165
235
  try {
166
- const result = await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: this.tableName }));
236
+ const result = await this.client.send(new client_dynamodb_1.DescribeTableCommand({ TableName: tableName }));
167
237
  if (result.Table?.TableStatus === 'ACTIVE') {
168
238
  return;
169
239
  }
@@ -173,21 +243,16 @@ class QueueStore {
173
243
  }
174
244
  await new Promise(resolve => setTimeout(resolve, 100));
175
245
  }
176
- throw new Error(`Table ${this.tableName} did not become active within ${maxWaitMs}ms`);
246
+ throw new Error(`Table \${tableName} did not become active within \${maxWaitMs}ms`);
177
247
  }
178
248
  /**
179
249
  * Enqueue a new task
180
250
  * Creates item with status=QUEUED
181
- *
182
- * @param sessionId - Session ID
183
- * @param taskGroupId - Task Group ID
184
- * @param prompt - User prompt
185
- * @param taskId - Optional task ID (generates if not provided)
186
- * @returns Created queue item
187
251
  */
188
252
  async enqueue(sessionId, taskGroupId, prompt, taskId) {
189
253
  const now = new Date().toISOString();
190
254
  const item = {
255
+ namespace: this.namespace,
191
256
  task_id: taskId || (0, uuid_1.v4)(),
192
257
  task_group_id: taskGroupId,
193
258
  session_id: sessionId,
@@ -197,52 +262,57 @@ class QueueStore {
197
262
  updated_at: now,
198
263
  };
199
264
  await this.docClient.send(new lib_dynamodb_1.PutCommand({
200
- TableName: this.tableName,
265
+ TableName: exports.QUEUE_TABLE_NAME,
201
266
  Item: item,
202
267
  }));
203
268
  return item;
204
269
  }
205
270
  /**
206
- * Get item by task_id
271
+ * Get item by task_id (v2: uses composite key)
207
272
  */
208
- async getItem(taskId) {
273
+ async getItem(taskId, targetNamespace) {
274
+ const ns = targetNamespace ?? this.namespace;
209
275
  const result = await this.docClient.send(new lib_dynamodb_1.GetCommand({
210
- TableName: this.tableName,
211
- Key: { task_id: taskId },
276
+ TableName: exports.QUEUE_TABLE_NAME,
277
+ Key: {
278
+ namespace: ns,
279
+ task_id: taskId
280
+ },
212
281
  }));
213
282
  return result.Item || null;
214
283
  }
215
284
  /**
216
- * Claim the oldest QUEUED task (atomic QUEUED -> RUNNING)
217
- * Per spec: Uses conditional update for double execution prevention
218
- *
219
- * @returns ClaimResult with success flag and item if claimed
285
+ * Claim the oldest QUEUED task for this namespace
220
286
  */
221
287
  async claim() {
222
- // Query for oldest QUEUED item using status-index
223
288
  const queryResult = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
224
- TableName: this.tableName,
289
+ TableName: exports.QUEUE_TABLE_NAME,
225
290
  IndexName: 'status-index',
226
291
  KeyConditionExpression: '#status = :queued',
292
+ FilterExpression: '#namespace = :namespace',
227
293
  ExpressionAttributeNames: {
228
294
  '#status': 'status',
295
+ '#namespace': 'namespace',
229
296
  },
230
297
  ExpressionAttributeValues: {
231
298
  ':queued': 'QUEUED',
299
+ ':namespace': this.namespace,
232
300
  },
233
- Limit: 1,
234
- ScanIndexForward: true, // Ascending by created_at (oldest first)
301
+ Limit: 10,
302
+ ScanIndexForward: true,
235
303
  }));
236
304
  if (!queryResult.Items || queryResult.Items.length === 0) {
237
305
  return { success: false };
238
306
  }
239
307
  const item = queryResult.Items[0];
240
- // Atomic update: QUEUED -> RUNNING with conditional check
241
308
  try {
242
309
  const now = new Date().toISOString();
243
310
  await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
244
- TableName: this.tableName,
245
- Key: { task_id: item.task_id },
311
+ TableName: exports.QUEUE_TABLE_NAME,
312
+ Key: {
313
+ namespace: this.namespace,
314
+ task_id: item.task_id
315
+ },
246
316
  UpdateExpression: 'SET #status = :running, updated_at = :now',
247
317
  ConditionExpression: '#status = :queued',
248
318
  ExpressionAttributeNames: {
@@ -254,30 +324,22 @@ class QueueStore {
254
324
  ':now': now,
255
325
  },
256
326
  }));
257
- // Update local item with new status
258
327
  item.status = 'RUNNING';
259
328
  item.updated_at = now;
260
329
  return { success: true, item };
261
330
  }
262
331
  catch (error) {
263
- // ConditionalCheckFailed means another process claimed it
264
332
  if (error &&
265
333
  typeof error === 'object' &&
266
334
  'name' in error &&
267
335
  error.name === 'ConditionalCheckFailedException') {
268
336
  return { success: false, error: 'Task already claimed by another process' };
269
337
  }
270
- // Fail-closed: re-throw unexpected errors
271
338
  throw error;
272
339
  }
273
340
  }
274
341
  /**
275
342
  * Update task status
276
- * Per spec: RUNNING -> COMPLETE or RUNNING -> ERROR
277
- *
278
- * @param taskId - Task ID
279
- * @param status - New status
280
- * @param errorMessage - Optional error message (for ERROR status)
281
343
  */
282
344
  async updateStatus(taskId, status, errorMessage) {
283
345
  const now = new Date().toISOString();
@@ -292,8 +354,11 @@ class QueueStore {
292
354
  expressionAttributeValues[':error'] = errorMessage;
293
355
  }
294
356
  await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
295
- TableName: this.tableName,
296
- Key: { task_id: taskId },
357
+ TableName: exports.QUEUE_TABLE_NAME,
358
+ Key: {
359
+ namespace: this.namespace,
360
+ task_id: taskId
361
+ },
297
362
  UpdateExpression: updateExpression,
298
363
  ExpressionAttributeNames: {
299
364
  '#status': 'status',
@@ -303,35 +368,27 @@ class QueueStore {
303
368
  }
304
369
  /**
305
370
  * Update task status with validation
306
- * Per spec/19_WEB_UI.md: PATCH /api/tasks/:task_id/status
307
- *
308
- * @param taskId - Task ID
309
- * @param newStatus - Target status
310
- * @returns StatusUpdateResult with success/error info
311
371
  */
312
372
  async updateStatusWithValidation(taskId, newStatus) {
313
- // First, get current task
314
373
  const task = await this.getItem(taskId);
315
374
  if (!task) {
316
375
  return {
317
376
  success: false,
318
377
  task_id: taskId,
319
378
  error: 'Task not found',
320
- message: `Task not found: ${taskId}`,
379
+ message: `Task not found: \${taskId}`,
321
380
  };
322
381
  }
323
382
  const oldStatus = task.status;
324
- // Check if transition is valid
325
383
  if (!isValidStatusTransition(oldStatus, newStatus)) {
326
384
  return {
327
385
  success: false,
328
386
  task_id: taskId,
329
387
  old_status: oldStatus,
330
388
  error: 'Invalid status transition',
331
- message: `Cannot transition from ${oldStatus} to ${newStatus}`,
389
+ message: `Cannot transition from \${oldStatus} to \${newStatus}`,
332
390
  };
333
391
  }
334
- // Update status
335
392
  await this.updateStatus(taskId, newStatus);
336
393
  return {
337
394
  success: true,
@@ -341,70 +398,70 @@ class QueueStore {
341
398
  };
342
399
  }
343
400
  /**
344
- * Get items by session ID
345
- * Uses session-index GSI
346
- */
347
- async getBySession(sessionId) {
348
- const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
349
- TableName: this.tableName,
350
- IndexName: 'session-index',
351
- KeyConditionExpression: 'session_id = :sid',
352
- ExpressionAttributeValues: {
353
- ':sid': sessionId,
354
- },
355
- ScanIndexForward: true, // Ascending by created_at
356
- }));
357
- return result.Items || [];
358
- }
359
- /**
360
- * Get items by status
361
- * Uses status-index GSI
401
+ * Get items by status for this namespace
362
402
  */
363
403
  async getByStatus(status) {
364
404
  const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
365
- TableName: this.tableName,
405
+ TableName: exports.QUEUE_TABLE_NAME,
366
406
  IndexName: 'status-index',
367
407
  KeyConditionExpression: '#status = :status',
408
+ FilterExpression: '#namespace = :namespace',
368
409
  ExpressionAttributeNames: {
369
410
  '#status': 'status',
411
+ '#namespace': 'namespace',
370
412
  },
371
413
  ExpressionAttributeValues: {
372
414
  ':status': status,
415
+ ':namespace': this.namespace,
373
416
  },
374
- ScanIndexForward: true, // Ascending by created_at
417
+ ScanIndexForward: true,
375
418
  }));
376
419
  return result.Items || [];
377
420
  }
378
421
  /**
379
- * Get items by task group ID
380
- * Uses task-group-index GSI
381
- * Per spec/19_WEB_UI.md: for listing tasks in a task group
422
+ * Get items by task group ID for this namespace
382
423
  */
383
- async getByTaskGroup(taskGroupId) {
424
+ async getByTaskGroup(taskGroupId, targetNamespace) {
425
+ const ns = targetNamespace ?? this.namespace;
384
426
  const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
385
- TableName: this.tableName,
427
+ TableName: exports.QUEUE_TABLE_NAME,
386
428
  IndexName: 'task-group-index',
387
429
  KeyConditionExpression: 'task_group_id = :tgid',
430
+ FilterExpression: '#namespace = :namespace',
431
+ ExpressionAttributeNames: {
432
+ '#namespace': 'namespace',
433
+ },
388
434
  ExpressionAttributeValues: {
389
435
  ':tgid': taskGroupId,
436
+ ':namespace': ns,
390
437
  },
391
- ScanIndexForward: true, // Ascending by created_at
438
+ ScanIndexForward: true,
392
439
  }));
393
440
  return result.Items || [];
394
441
  }
395
442
  /**
396
- * Get all distinct task groups with summary
397
- * Per spec/19_WEB_UI.md: for task group list view
398
- * Note: Uses Scan - consider pagination for large datasets
443
+ * Get all items in a namespace
399
444
  */
400
- async getAllTaskGroups() {
401
- // Scan all items and aggregate by task_group_id
402
- const result = await this.docClient.send(new lib_dynamodb_1.ScanCommand({
403
- TableName: this.tableName,
404
- ProjectionExpression: 'task_group_id, created_at, updated_at',
445
+ async getAllItems(targetNamespace) {
446
+ const ns = targetNamespace ?? this.namespace;
447
+ const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
448
+ TableName: exports.QUEUE_TABLE_NAME,
449
+ KeyConditionExpression: '#namespace = :namespace',
450
+ ExpressionAttributeNames: {
451
+ '#namespace': 'namespace',
452
+ },
453
+ ExpressionAttributeValues: {
454
+ ':namespace': ns,
455
+ },
456
+ ScanIndexForward: true,
405
457
  }));
406
- const items = result.Items || [];
407
- // Aggregate by task_group_id
458
+ return result.Items || [];
459
+ }
460
+ /**
461
+ * Get all distinct task groups for a namespace with summary
462
+ */
463
+ async getAllTaskGroups(targetNamespace) {
464
+ const items = await this.getAllItems(targetNamespace);
408
465
  const groupMap = new Map();
409
466
  for (const item of items) {
410
467
  const existing = groupMap.get(item.task_group_id);
@@ -425,7 +482,6 @@ class QueueStore {
425
482
  });
426
483
  }
427
484
  }
428
- // Convert to array and sort by created_at (oldest first)
429
485
  const groups = [];
430
486
  for (const [taskGroupId, data] of groupMap) {
431
487
  groups.push({
@@ -438,21 +494,73 @@ class QueueStore {
438
494
  groups.sort((a, b) => a.created_at.localeCompare(b.created_at));
439
495
  return groups;
440
496
  }
497
+ /**
498
+ * Get all distinct namespaces (scan entire table)
499
+ */
500
+ async getAllNamespaces() {
501
+ const queueResult = await this.docClient.send(new lib_dynamodb_1.ScanCommand({
502
+ TableName: exports.QUEUE_TABLE_NAME,
503
+ ProjectionExpression: '#namespace',
504
+ ExpressionAttributeNames: {
505
+ '#namespace': 'namespace',
506
+ },
507
+ }));
508
+ let runnersResult = { Items: [] };
509
+ try {
510
+ runnersResult = await this.docClient.send(new lib_dynamodb_1.ScanCommand({
511
+ TableName: exports.RUNNERS_TABLE_NAME,
512
+ }));
513
+ }
514
+ catch {
515
+ // Runners table might not exist yet
516
+ }
517
+ const queueItems = (queueResult.Items || []);
518
+ const runners = (runnersResult.Items || []);
519
+ const taskCounts = new Map();
520
+ for (const item of queueItems) {
521
+ const count = taskCounts.get(item.namespace) || 0;
522
+ taskCounts.set(item.namespace, count + 1);
523
+ }
524
+ const runnerCounts = new Map();
525
+ const now = Date.now();
526
+ const HEARTBEAT_TIMEOUT_MS = 2 * 60 * 1000;
527
+ for (const runner of runners) {
528
+ const counts = runnerCounts.get(runner.namespace) || { total: 0, active: 0 };
529
+ counts.total++;
530
+ const lastHeartbeat = new Date(runner.last_heartbeat).getTime();
531
+ if (now - lastHeartbeat < HEARTBEAT_TIMEOUT_MS) {
532
+ counts.active++;
533
+ }
534
+ runnerCounts.set(runner.namespace, counts);
535
+ }
536
+ const allNamespaces = new Set([...taskCounts.keys(), ...runnerCounts.keys()]);
537
+ const summaries = [];
538
+ for (const ns of allNamespaces) {
539
+ const runnerInfo = runnerCounts.get(ns) || { total: 0, active: 0 };
540
+ summaries.push({
541
+ namespace: ns,
542
+ task_count: taskCounts.get(ns) || 0,
543
+ runner_count: runnerInfo.total,
544
+ active_runner_count: runnerInfo.active,
545
+ });
546
+ }
547
+ summaries.sort((a, b) => a.namespace.localeCompare(b.namespace));
548
+ return summaries;
549
+ }
441
550
  /**
442
551
  * Delete item (for testing)
443
552
  */
444
553
  async deleteItem(taskId) {
445
554
  await this.docClient.send(new lib_dynamodb_1.DeleteCommand({
446
- TableName: this.tableName,
447
- Key: { task_id: taskId },
555
+ TableName: exports.QUEUE_TABLE_NAME,
556
+ Key: {
557
+ namespace: this.namespace,
558
+ task_id: taskId
559
+ },
448
560
  }));
449
561
  }
450
562
  /**
451
563
  * Mark stale RUNNING tasks as ERROR
452
- * Per spec: fail-closed - don't leave tasks in "limbo"
453
- *
454
- * @param maxAgeMs - Max age in milliseconds for RUNNING tasks
455
- * @returns Number of tasks marked as ERROR
456
564
  */
457
565
  async recoverStaleTasks(maxAgeMs = 5 * 60 * 1000) {
458
566
  const runningTasks = await this.getByStatus('RUNNING');
@@ -461,12 +569,125 @@ class QueueStore {
461
569
  for (const task of runningTasks) {
462
570
  const taskAge = now - new Date(task.updated_at).getTime();
463
571
  if (taskAge > maxAgeMs) {
464
- await this.updateStatus(task.task_id, 'ERROR', `Task stale: running for ${Math.round(taskAge / 1000)}s without completion`);
572
+ await this.updateStatus(task.task_id, 'ERROR', `Task stale: running for \${Math.round(taskAge / 1000)}s without completion`);
465
573
  recovered++;
466
574
  }
467
575
  }
468
576
  return recovered;
469
577
  }
578
+ // ===============================
579
+ // Runner Heartbeat Methods (v2)
580
+ // ===============================
581
+ /**
582
+ * Register or update runner heartbeat
583
+ */
584
+ async updateRunnerHeartbeat(runnerId, projectRoot) {
585
+ const now = new Date().toISOString();
586
+ const existing = await this.getRunner(runnerId);
587
+ if (existing) {
588
+ await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
589
+ TableName: exports.RUNNERS_TABLE_NAME,
590
+ Key: {
591
+ namespace: this.namespace,
592
+ runner_id: runnerId,
593
+ },
594
+ UpdateExpression: 'SET last_heartbeat = :now, #status = :status',
595
+ ExpressionAttributeNames: {
596
+ '#status': 'status',
597
+ },
598
+ ExpressionAttributeValues: {
599
+ ':now': now,
600
+ ':status': 'RUNNING',
601
+ },
602
+ }));
603
+ }
604
+ else {
605
+ const record = {
606
+ namespace: this.namespace,
607
+ runner_id: runnerId,
608
+ last_heartbeat: now,
609
+ started_at: now,
610
+ status: 'RUNNING',
611
+ project_root: projectRoot,
612
+ };
613
+ await this.docClient.send(new lib_dynamodb_1.PutCommand({
614
+ TableName: exports.RUNNERS_TABLE_NAME,
615
+ Item: record,
616
+ }));
617
+ }
618
+ }
619
+ /**
620
+ * Get runner by ID
621
+ */
622
+ async getRunner(runnerId) {
623
+ const result = await this.docClient.send(new lib_dynamodb_1.GetCommand({
624
+ TableName: exports.RUNNERS_TABLE_NAME,
625
+ Key: {
626
+ namespace: this.namespace,
627
+ runner_id: runnerId,
628
+ },
629
+ }));
630
+ return result.Item || null;
631
+ }
632
+ /**
633
+ * Get all runners for this namespace
634
+ */
635
+ async getAllRunners(targetNamespace) {
636
+ const ns = targetNamespace ?? this.namespace;
637
+ const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
638
+ TableName: exports.RUNNERS_TABLE_NAME,
639
+ KeyConditionExpression: '#namespace = :namespace',
640
+ ExpressionAttributeNames: {
641
+ '#namespace': 'namespace',
642
+ },
643
+ ExpressionAttributeValues: {
644
+ ':namespace': ns,
645
+ },
646
+ }));
647
+ return result.Items || [];
648
+ }
649
+ /**
650
+ * Get runners with their alive status
651
+ */
652
+ async getRunnersWithStatus(heartbeatTimeoutMs = 2 * 60 * 1000, targetNamespace) {
653
+ const runners = await this.getAllRunners(targetNamespace);
654
+ const now = Date.now();
655
+ return runners.map(runner => ({
656
+ ...runner,
657
+ isAlive: now - new Date(runner.last_heartbeat).getTime() < heartbeatTimeoutMs,
658
+ }));
659
+ }
660
+ /**
661
+ * Mark runner as stopped
662
+ */
663
+ async markRunnerStopped(runnerId) {
664
+ await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
665
+ TableName: exports.RUNNERS_TABLE_NAME,
666
+ Key: {
667
+ namespace: this.namespace,
668
+ runner_id: runnerId,
669
+ },
670
+ UpdateExpression: 'SET #status = :status',
671
+ ExpressionAttributeNames: {
672
+ '#status': 'status',
673
+ },
674
+ ExpressionAttributeValues: {
675
+ ':status': 'STOPPED',
676
+ },
677
+ }));
678
+ }
679
+ /**
680
+ * Delete runner record
681
+ */
682
+ async deleteRunner(runnerId) {
683
+ await this.docClient.send(new lib_dynamodb_1.DeleteCommand({
684
+ TableName: exports.RUNNERS_TABLE_NAME,
685
+ Key: {
686
+ namespace: this.namespace,
687
+ runner_id: runnerId,
688
+ },
689
+ }));
690
+ }
470
691
  /**
471
692
  * Close the client connection
472
693
  */