pm-orchestrator-runner 1.0.13 → 1.0.15

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,30 +1,63 @@
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 = void 0;
18
+ exports.QueueStore = exports.VALID_STATUS_TRANSITIONS = exports.RUNNERS_TABLE_NAME = exports.QUEUE_TABLE_NAME = void 0;
19
+ exports.isValidStatusTransition = isValidStatusTransition;
13
20
  const client_dynamodb_1 = require("@aws-sdk/client-dynamodb");
14
21
  const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb");
15
22
  const uuid_1 = require("uuid");
16
23
  /**
17
- * Queue Store
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';
31
+ /**
32
+ * Valid status transitions
33
+ * Per spec/20_QUEUE_STORE.md
34
+ */
35
+ exports.VALID_STATUS_TRANSITIONS = {
36
+ QUEUED: ['RUNNING', 'CANCELLED'],
37
+ RUNNING: ['COMPLETE', 'ERROR', 'CANCELLED'],
38
+ COMPLETE: [], // Terminal state
39
+ ERROR: [], // Terminal state
40
+ CANCELLED: [], // Terminal state
41
+ };
42
+ /**
43
+ * Check if a status transition is valid
44
+ */
45
+ function isValidStatusTransition(fromStatus, toStatus) {
46
+ return exports.VALID_STATUS_TRANSITIONS[fromStatus].includes(toStatus);
47
+ }
48
+ /**
49
+ * Queue Store (v2)
18
50
  * Manages task queue with DynamoDB Local
51
+ * Single table design with namespace-based separation
19
52
  */
20
53
  class QueueStore {
21
54
  client;
22
55
  docClient;
23
- tableName;
56
+ namespace;
24
57
  endpoint;
25
- constructor(config = {}) {
58
+ constructor(config) {
26
59
  this.endpoint = config.endpoint || 'http://localhost:8000';
27
- this.tableName = config.tableName || 'pm-runner-queue';
60
+ this.namespace = config.namespace;
28
61
  const region = config.region || 'local';
29
62
  this.client = new client_dynamodb_1.DynamoDBClient({
30
63
  endpoint: this.endpoint,
@@ -41,10 +74,16 @@ class QueueStore {
41
74
  });
42
75
  }
43
76
  /**
44
- * Get table name
77
+ * Get fixed table name (v2: always returns pm-runner-queue)
45
78
  */
46
79
  getTableName() {
47
- return this.tableName;
80
+ return exports.QUEUE_TABLE_NAME;
81
+ }
82
+ /**
83
+ * Get namespace
84
+ */
85
+ getNamespace() {
86
+ return this.namespace;
48
87
  }
49
88
  /**
50
89
  * Get endpoint
@@ -57,7 +96,7 @@ class QueueStore {
57
96
  */
58
97
  async tableExists() {
59
98
  try {
60
- 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 }));
61
100
  return true;
62
101
  }
63
102
  catch (error) {
@@ -68,33 +107,39 @@ class QueueStore {
68
107
  }
69
108
  }
70
109
  /**
71
- * Create table with required GSIs
72
- * 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
73
127
  */
74
128
  async createTable() {
75
129
  const command = new client_dynamodb_1.CreateTableCommand({
76
- TableName: this.tableName,
77
- 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
+ ],
78
135
  AttributeDefinitions: [
136
+ { AttributeName: 'namespace', AttributeType: 'S' },
79
137
  { AttributeName: 'task_id', AttributeType: 'S' },
80
- { AttributeName: 'session_id', AttributeType: 'S' },
81
138
  { AttributeName: 'status', AttributeType: 'S' },
82
139
  { AttributeName: 'created_at', AttributeType: 'S' },
83
140
  { AttributeName: 'task_group_id', AttributeType: 'S' },
84
141
  ],
85
142
  GlobalSecondaryIndexes: [
86
- {
87
- IndexName: 'session-index',
88
- KeySchema: [
89
- { AttributeName: 'session_id', KeyType: 'HASH' },
90
- { AttributeName: 'created_at', KeyType: 'RANGE' },
91
- ],
92
- Projection: { ProjectionType: 'ALL' },
93
- ProvisionedThroughput: {
94
- ReadCapacityUnits: 5,
95
- WriteCapacityUnits: 5,
96
- },
97
- },
98
143
  {
99
144
  IndexName: 'status-index',
100
145
  KeySchema: [
@@ -128,24 +173,67 @@ class QueueStore {
128
173
  await this.client.send(command);
129
174
  }
130
175
  /**
131
- * 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
132
214
  */
133
215
  async ensureTable() {
134
- const exists = await this.tableExists();
135
- if (!exists) {
216
+ // Queue table
217
+ const queueExists = await this.tableExists();
218
+ if (!queueExists) {
136
219
  await this.createTable();
137
- // Wait for table to be active
138
- 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);
139
227
  }
140
228
  }
141
229
  /**
142
230
  * Wait for table to become active
143
231
  */
144
- async waitForTableActive(maxWaitMs = 30000) {
232
+ async waitForTableActive(tableName, maxWaitMs = 30000) {
145
233
  const startTime = Date.now();
146
234
  while (Date.now() - startTime < maxWaitMs) {
147
235
  try {
148
- 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 }));
149
237
  if (result.Table?.TableStatus === 'ACTIVE') {
150
238
  return;
151
239
  }
@@ -155,21 +243,16 @@ class QueueStore {
155
243
  }
156
244
  await new Promise(resolve => setTimeout(resolve, 100));
157
245
  }
158
- 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`);
159
247
  }
160
248
  /**
161
249
  * Enqueue a new task
162
250
  * Creates item with status=QUEUED
163
- *
164
- * @param sessionId - Session ID
165
- * @param taskGroupId - Task Group ID
166
- * @param prompt - User prompt
167
- * @param taskId - Optional task ID (generates if not provided)
168
- * @returns Created queue item
169
251
  */
170
252
  async enqueue(sessionId, taskGroupId, prompt, taskId) {
171
253
  const now = new Date().toISOString();
172
254
  const item = {
255
+ namespace: this.namespace,
173
256
  task_id: taskId || (0, uuid_1.v4)(),
174
257
  task_group_id: taskGroupId,
175
258
  session_id: sessionId,
@@ -179,52 +262,57 @@ class QueueStore {
179
262
  updated_at: now,
180
263
  };
181
264
  await this.docClient.send(new lib_dynamodb_1.PutCommand({
182
- TableName: this.tableName,
265
+ TableName: exports.QUEUE_TABLE_NAME,
183
266
  Item: item,
184
267
  }));
185
268
  return item;
186
269
  }
187
270
  /**
188
- * Get item by task_id
271
+ * Get item by task_id (v2: uses composite key)
189
272
  */
190
- async getItem(taskId) {
273
+ async getItem(taskId, targetNamespace) {
274
+ const ns = targetNamespace ?? this.namespace;
191
275
  const result = await this.docClient.send(new lib_dynamodb_1.GetCommand({
192
- TableName: this.tableName,
193
- Key: { task_id: taskId },
276
+ TableName: exports.QUEUE_TABLE_NAME,
277
+ Key: {
278
+ namespace: ns,
279
+ task_id: taskId
280
+ },
194
281
  }));
195
282
  return result.Item || null;
196
283
  }
197
284
  /**
198
- * Claim the oldest QUEUED task (atomic QUEUED -> RUNNING)
199
- * Per spec: Uses conditional update for double execution prevention
200
- *
201
- * @returns ClaimResult with success flag and item if claimed
285
+ * Claim the oldest QUEUED task for this namespace
202
286
  */
203
287
  async claim() {
204
- // Query for oldest QUEUED item using status-index
205
288
  const queryResult = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
206
- TableName: this.tableName,
289
+ TableName: exports.QUEUE_TABLE_NAME,
207
290
  IndexName: 'status-index',
208
291
  KeyConditionExpression: '#status = :queued',
292
+ FilterExpression: '#namespace = :namespace',
209
293
  ExpressionAttributeNames: {
210
294
  '#status': 'status',
295
+ '#namespace': 'namespace',
211
296
  },
212
297
  ExpressionAttributeValues: {
213
298
  ':queued': 'QUEUED',
299
+ ':namespace': this.namespace,
214
300
  },
215
- Limit: 1,
216
- ScanIndexForward: true, // Ascending by created_at (oldest first)
301
+ Limit: 10,
302
+ ScanIndexForward: true,
217
303
  }));
218
304
  if (!queryResult.Items || queryResult.Items.length === 0) {
219
305
  return { success: false };
220
306
  }
221
307
  const item = queryResult.Items[0];
222
- // Atomic update: QUEUED -> RUNNING with conditional check
223
308
  try {
224
309
  const now = new Date().toISOString();
225
310
  await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
226
- TableName: this.tableName,
227
- 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
+ },
228
316
  UpdateExpression: 'SET #status = :running, updated_at = :now',
229
317
  ConditionExpression: '#status = :queued',
230
318
  ExpressionAttributeNames: {
@@ -236,30 +324,22 @@ class QueueStore {
236
324
  ':now': now,
237
325
  },
238
326
  }));
239
- // Update local item with new status
240
327
  item.status = 'RUNNING';
241
328
  item.updated_at = now;
242
329
  return { success: true, item };
243
330
  }
244
331
  catch (error) {
245
- // ConditionalCheckFailed means another process claimed it
246
332
  if (error &&
247
333
  typeof error === 'object' &&
248
334
  'name' in error &&
249
335
  error.name === 'ConditionalCheckFailedException') {
250
336
  return { success: false, error: 'Task already claimed by another process' };
251
337
  }
252
- // Fail-closed: re-throw unexpected errors
253
338
  throw error;
254
339
  }
255
340
  }
256
341
  /**
257
342
  * Update task status
258
- * Per spec: RUNNING -> COMPLETE or RUNNING -> ERROR
259
- *
260
- * @param taskId - Task ID
261
- * @param status - New status
262
- * @param errorMessage - Optional error message (for ERROR status)
263
343
  */
264
344
  async updateStatus(taskId, status, errorMessage) {
265
345
  const now = new Date().toISOString();
@@ -274,8 +354,11 @@ class QueueStore {
274
354
  expressionAttributeValues[':error'] = errorMessage;
275
355
  }
276
356
  await this.docClient.send(new lib_dynamodb_1.UpdateCommand({
277
- TableName: this.tableName,
278
- Key: { task_id: taskId },
357
+ TableName: exports.QUEUE_TABLE_NAME,
358
+ Key: {
359
+ namespace: this.namespace,
360
+ task_id: taskId
361
+ },
279
362
  UpdateExpression: updateExpression,
280
363
  ExpressionAttributeNames: {
281
364
  '#status': 'status',
@@ -284,70 +367,101 @@ class QueueStore {
284
367
  }));
285
368
  }
286
369
  /**
287
- * Get items by session ID
288
- * Uses session-index GSI
370
+ * Update task status with validation
289
371
  */
290
- async getBySession(sessionId) {
291
- const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
292
- TableName: this.tableName,
293
- IndexName: 'session-index',
294
- KeyConditionExpression: 'session_id = :sid',
295
- ExpressionAttributeValues: {
296
- ':sid': sessionId,
297
- },
298
- ScanIndexForward: true, // Ascending by created_at
299
- }));
300
- return result.Items || [];
372
+ async updateStatusWithValidation(taskId, newStatus) {
373
+ const task = await this.getItem(taskId);
374
+ if (!task) {
375
+ return {
376
+ success: false,
377
+ task_id: taskId,
378
+ error: 'Task not found',
379
+ message: `Task not found: \${taskId}`,
380
+ };
381
+ }
382
+ const oldStatus = task.status;
383
+ if (!isValidStatusTransition(oldStatus, newStatus)) {
384
+ return {
385
+ success: false,
386
+ task_id: taskId,
387
+ old_status: oldStatus,
388
+ error: 'Invalid status transition',
389
+ message: `Cannot transition from \${oldStatus} to \${newStatus}`,
390
+ };
391
+ }
392
+ await this.updateStatus(taskId, newStatus);
393
+ return {
394
+ success: true,
395
+ task_id: taskId,
396
+ old_status: oldStatus,
397
+ new_status: newStatus,
398
+ };
301
399
  }
302
400
  /**
303
- * Get items by status
304
- * Uses status-index GSI
401
+ * Get items by status for this namespace
305
402
  */
306
403
  async getByStatus(status) {
307
404
  const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
308
- TableName: this.tableName,
405
+ TableName: exports.QUEUE_TABLE_NAME,
309
406
  IndexName: 'status-index',
310
407
  KeyConditionExpression: '#status = :status',
408
+ FilterExpression: '#namespace = :namespace',
311
409
  ExpressionAttributeNames: {
312
410
  '#status': 'status',
411
+ '#namespace': 'namespace',
313
412
  },
314
413
  ExpressionAttributeValues: {
315
414
  ':status': status,
415
+ ':namespace': this.namespace,
316
416
  },
317
- ScanIndexForward: true, // Ascending by created_at
417
+ ScanIndexForward: true,
318
418
  }));
319
419
  return result.Items || [];
320
420
  }
321
421
  /**
322
- * Get items by task group ID
323
- * Uses task-group-index GSI
324
- * Per spec/19_WEB_UI.md: for listing tasks in a task group
422
+ * Get items by task group ID for this namespace
325
423
  */
326
- async getByTaskGroup(taskGroupId) {
424
+ async getByTaskGroup(taskGroupId, targetNamespace) {
425
+ const ns = targetNamespace ?? this.namespace;
327
426
  const result = await this.docClient.send(new lib_dynamodb_1.QueryCommand({
328
- TableName: this.tableName,
427
+ TableName: exports.QUEUE_TABLE_NAME,
329
428
  IndexName: 'task-group-index',
330
429
  KeyConditionExpression: 'task_group_id = :tgid',
430
+ FilterExpression: '#namespace = :namespace',
431
+ ExpressionAttributeNames: {
432
+ '#namespace': 'namespace',
433
+ },
331
434
  ExpressionAttributeValues: {
332
435
  ':tgid': taskGroupId,
436
+ ':namespace': ns,
333
437
  },
334
- ScanIndexForward: true, // Ascending by created_at
438
+ ScanIndexForward: true,
335
439
  }));
336
440
  return result.Items || [];
337
441
  }
338
442
  /**
339
- * Get all distinct task groups with summary
340
- * Per spec/19_WEB_UI.md: for task group list view
341
- * Note: Uses Scan - consider pagination for large datasets
443
+ * Get all items in a namespace
342
444
  */
343
- async getAllTaskGroups() {
344
- // Scan all items and aggregate by task_group_id
345
- const result = await this.docClient.send(new lib_dynamodb_1.ScanCommand({
346
- TableName: this.tableName,
347
- 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,
348
457
  }));
349
- const items = result.Items || [];
350
- // 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);
351
465
  const groupMap = new Map();
352
466
  for (const item of items) {
353
467
  const existing = groupMap.get(item.task_group_id);
@@ -368,7 +482,6 @@ class QueueStore {
368
482
  });
369
483
  }
370
484
  }
371
- // Convert to array and sort by created_at (oldest first)
372
485
  const groups = [];
373
486
  for (const [taskGroupId, data] of groupMap) {
374
487
  groups.push({
@@ -381,21 +494,73 @@ class QueueStore {
381
494
  groups.sort((a, b) => a.created_at.localeCompare(b.created_at));
382
495
  return groups;
383
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
+ }
384
550
  /**
385
551
  * Delete item (for testing)
386
552
  */
387
553
  async deleteItem(taskId) {
388
554
  await this.docClient.send(new lib_dynamodb_1.DeleteCommand({
389
- TableName: this.tableName,
390
- Key: { task_id: taskId },
555
+ TableName: exports.QUEUE_TABLE_NAME,
556
+ Key: {
557
+ namespace: this.namespace,
558
+ task_id: taskId
559
+ },
391
560
  }));
392
561
  }
393
562
  /**
394
563
  * Mark stale RUNNING tasks as ERROR
395
- * Per spec: fail-closed - don't leave tasks in "limbo"
396
- *
397
- * @param maxAgeMs - Max age in milliseconds for RUNNING tasks
398
- * @returns Number of tasks marked as ERROR
399
564
  */
400
565
  async recoverStaleTasks(maxAgeMs = 5 * 60 * 1000) {
401
566
  const runningTasks = await this.getByStatus('RUNNING');
@@ -404,12 +569,125 @@ class QueueStore {
404
569
  for (const task of runningTasks) {
405
570
  const taskAge = now - new Date(task.updated_at).getTime();
406
571
  if (taskAge > maxAgeMs) {
407
- 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`);
408
573
  recovered++;
409
574
  }
410
575
  }
411
576
  return recovered;
412
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
+ }
413
691
  /**
414
692
  * Close the client connection
415
693
  */