@atolis-hq/wake 0.2.29 → 0.2.30

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.
@@ -218,6 +218,7 @@ export function createClaudeRunner(options) {
218
218
  args,
219
219
  cwd: input.workspacePath ?? options.cwd,
220
220
  timeoutMs: options.settings.timeoutMs,
221
+ ...(input.onProcessStart === undefined ? {} : { onProcessStart: input.onProcessStart }),
221
222
  });
222
223
  const responseTranscriptPath = await writeRunnerTranscript({
223
224
  config: input.config,
@@ -261,6 +261,7 @@ export function createCodexRunner(options) {
261
261
  }),
262
262
  cwd,
263
263
  timeoutMs: options.settings.timeoutMs,
264
+ ...(input.onProcessStart === undefined ? {} : { onProcessStart: input.onProcessStart }),
264
265
  });
265
266
  const responseTranscriptPath = await writeRunnerTranscript({
266
267
  config: input.config,
@@ -212,6 +212,7 @@ export function createCursorRunner(options) {
212
212
  }),
213
213
  cwd,
214
214
  timeoutMs: options.settings.timeoutMs,
215
+ ...(input.onProcessStart === undefined ? {} : { onProcessStart: input.onProcessStart }),
215
216
  });
216
217
  const responseTranscriptPath = await writeRunnerTranscript({
217
218
  config: input.config,
@@ -172,6 +172,13 @@ export function createStateStore({ wakeRoot }) {
172
172
  await writeJsonFile(paths.runDateFile(parsed.startedAt.slice(0, 10), parsed.runId), parsed);
173
173
  return parsed;
174
174
  },
175
+ async updateRunRecordIf(runId, input) {
176
+ const current = await this.readRunRecord(runId);
177
+ if (current === null || !input.expect(current)) {
178
+ return null;
179
+ }
180
+ return this.writeRunRecord(input.update(current));
181
+ },
175
182
  async writeSourceState(record) {
176
183
  const parsed = parseSourceStateRecord(record);
177
184
  await writeJsonFile(paths.sourceStateFile(parsed.source, parsed.key), parsed);
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { readProcessIdentity } from '../../lib/process-identity.js';
2
3
  const TIMEOUT_KILL_GRACE_MS = 5_000;
3
4
  export function runAgentCliCommand(input) {
4
5
  return new Promise((resolve, reject) => {
@@ -11,6 +12,16 @@ export function runAgentCliCommand(input) {
11
12
  let stderr = '';
12
13
  let timedOut = false;
13
14
  let killTimer;
15
+ let startNotification = Promise.resolve();
16
+ let startNotificationError;
17
+ if (input.onProcessStart !== undefined && child.pid !== undefined) {
18
+ const identity = readProcessIdentity(child.pid);
19
+ if (identity !== null) {
20
+ startNotification = input.onProcessStart(identity).catch((error) => {
21
+ startNotificationError = error;
22
+ });
23
+ }
24
+ }
14
25
  const timeoutTimer = input.timeoutMs === undefined
15
26
  ? undefined
16
27
  : setTimeout(() => {
@@ -29,9 +40,14 @@ export function runAgentCliCommand(input) {
29
40
  clearTimeout(killTimer);
30
41
  reject(error);
31
42
  });
32
- child.on('close', (exitCode) => {
43
+ child.on('close', async (exitCode) => {
33
44
  clearTimeout(timeoutTimer);
34
45
  clearTimeout(killTimer);
46
+ await startNotification;
47
+ if (startNotificationError !== undefined) {
48
+ reject(startNotificationError);
49
+ return;
50
+ }
35
51
  resolve({
36
52
  stdout,
37
53
  stderr,
@@ -1,5 +1,6 @@
1
- import { readFileLockStatus } from '../lib/lock.js';
2
1
  import { maxConfiguredRunnerTimeoutMs } from '../domain/runner-routing.js';
2
+ import { isRunLeaseExpired } from './run-lease.js';
3
+ import { processIdentityMatches } from '../lib/process-identity.js';
3
4
  import { createOutbox } from './outbox.js';
4
5
  import { createProjectionUpdater } from './projection-updater.js';
5
6
  import { createStaleRunReconciler } from './stale-run-reconciler.js';
@@ -14,14 +15,18 @@ export function createActiveRunRecovery(deps) {
14
15
  stateStore: deps.stateStore,
15
16
  projectionUpdater,
16
17
  });
17
- async function isRunningRecordActive(record) {
18
- const lock = await readFileLockStatus(deps.stateStore.paths.runnerLockFile, {
19
- expectedCommandIncludes: process.argv[1] === undefined ? [] : [process.argv[1]],
20
- });
21
- if (!lock.active || lock.metadata === undefined) {
22
- return false;
18
+ async function isRunningRecordActive(record, now) {
19
+ if (record.lease !== undefined && !isRunLeaseExpired(record, now)) {
20
+ return true;
23
21
  }
24
- return Date.parse(lock.metadata.acquiredAt) <= Date.parse(record.startedAt);
22
+ return (processIdentityMatches({
23
+ pid: record.agentPid,
24
+ processStartedAt: record.agentProcessStartedAt,
25
+ }) ||
26
+ processIdentityMatches({
27
+ pid: record.workerPid,
28
+ processStartedAt: record.workerProcessStartedAt,
29
+ }));
25
30
  }
26
31
  const { reconcileStaleRunningRecords } = createStaleRunReconciler({
27
32
  config: deps.config,
@@ -0,0 +1,38 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export const runLeaseDurationMs = 60_000;
3
+ export const runLeaseRenewalIntervalMs = 20_000;
4
+ export function createRunLease(input) {
5
+ const acquiredAt = input.clock.now();
6
+ const expiresAt = new Date(acquiredAt.getTime() + runLeaseDurationMs);
7
+ return {
8
+ leaseId: `lease-${randomUUID()}`,
9
+ ownerInstanceId: input.ownerInstanceId,
10
+ acquiredAt: acquiredAt.toISOString(),
11
+ lastRenewedAt: acquiredAt.toISOString(),
12
+ expiresAt: expiresAt.toISOString(),
13
+ };
14
+ }
15
+ export function isRunLeaseExpired(record, now) {
16
+ if (record.lease === undefined) {
17
+ return true;
18
+ }
19
+ return Date.parse(record.lease.expiresAt) <= now.getTime();
20
+ }
21
+ export async function renewRunLease(input) {
22
+ const now = input.clock.now();
23
+ const renewed = await input.stateStore.updateRunRecordIf(input.runId, {
24
+ expect: (record) => record.status === 'running' &&
25
+ record.lifecycle !== 'TERMINAL' &&
26
+ record.lease?.leaseId === input.leaseId &&
27
+ record.lease.ownerInstanceId === input.ownerInstanceId,
28
+ update: (record) => ({
29
+ ...record,
30
+ lease: {
31
+ ...record.lease,
32
+ lastRenewedAt: now.toISOString(),
33
+ expiresAt: new Date(now.getTime() + runLeaseDurationMs).toISOString(),
34
+ },
35
+ }),
36
+ });
37
+ return renewed !== null;
38
+ }
@@ -44,13 +44,9 @@ export function createStaleRunReconciler(deps) {
44
44
  }
45
45
  return 'startup-recovery';
46
46
  }
47
- const active = await deps.isRunningRecordActive(record);
47
+ const active = await deps.isRunningRecordActive(record, now);
48
48
  if (active) {
49
- const startedAtMs = Date.parse(record.startedAt);
50
- if (!Number.isFinite(startedAtMs)) {
51
- return 'timeout';
52
- }
53
- return now.getTime() - startedAtMs >= deps.runnerTimeoutMs() ? 'timeout' : null;
49
+ return null;
54
50
  }
55
51
  if (record.lifecycle === 'RUNNING' || record.lifecycle === 'PROCESS_STARTING') {
56
52
  return 'runner-lock-not-active';
@@ -1,7 +1,7 @@
1
1
  import { createLifecycleService } from './lifecycle-service.js';
2
2
  import { createPolicyEngine } from './policy-engine.js';
3
3
  import { createProjectionUpdater } from './projection-updater.js';
4
- import { acquireFileLock, readFileLockStatus } from '../lib/lock.js';
4
+ import { acquireFileLock } from '../lib/lock.js';
5
5
  import { CORRELATION_REGISTERED_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
6
6
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
7
7
  import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
@@ -15,6 +15,8 @@ import { createOutbox } from './outbox.js';
15
15
  import { createEventResolver } from './event-resolver.js';
16
16
  import { createStaleRunReconciler } from './stale-run-reconciler.js';
17
17
  import { createWorkspaceCleanup } from './workspace-cleanup.js';
18
+ import { createRunLease, isRunLeaseExpired, renewRunLease, runLeaseRenewalIntervalMs, } from './run-lease.js';
19
+ import { currentProcessIdentity, processIdentityMatches } from '../lib/process-identity.js';
18
20
  function latestHumanCommentId(candidate) {
19
21
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
20
22
  return human.at(-1)?.id;
@@ -68,6 +70,7 @@ export function createTickRunner(deps) {
68
70
  workspaceManager: deps.workspaceManager,
69
71
  projectionUpdater,
70
72
  });
73
+ const ownerInstanceId = `instance-${process.pid}-${Date.now()}`;
71
74
  function isAwaitingApproval(projection) {
72
75
  return projection.context.lastRunSentinel === awaitingApprovalRunnerSentinel;
73
76
  }
@@ -204,14 +207,18 @@ export function createTickRunner(deps) {
204
207
  function runnerTimeoutMs() {
205
208
  return maxConfiguredRunnerTimeoutMs(deps.config);
206
209
  }
207
- async function isRunningRecordActive(record) {
208
- const lock = await readFileLockStatus(deps.stateStore.paths.runnerLockFile, {
209
- expectedCommandIncludes: process.argv[1] === undefined ? [] : [process.argv[1]],
210
- });
211
- if (!lock.active || lock.metadata === undefined) {
212
- return false;
210
+ async function isRunningRecordActive(record, now) {
211
+ if (record.lease !== undefined && !isRunLeaseExpired(record, now)) {
212
+ return true;
213
213
  }
214
- return Date.parse(lock.metadata.acquiredAt) <= Date.parse(record.startedAt);
214
+ return (processIdentityMatches({
215
+ pid: record.agentPid,
216
+ processStartedAt: record.agentProcessStartedAt,
217
+ }) ||
218
+ processIdentityMatches({
219
+ pid: record.workerPid,
220
+ processStartedAt: record.workerProcessStartedAt,
221
+ }));
215
222
  }
216
223
  async function parkConfigDriftedProjections(projections) {
217
224
  let parked = false;
@@ -289,9 +296,7 @@ export function createTickRunner(deps) {
289
296
  }
290
297
  }
291
298
  async function runRunnerTick() {
292
- const lock = await acquireFileLock(deps.stateStore.paths.runnerLockFile, {
293
- staleAfterMs: runnerTimeoutMs(),
294
- });
299
+ const lock = await acquireFileLock(deps.stateStore.paths.runnerLockFile);
295
300
  if (!lock.acquired) {
296
301
  return { status: 'locked' };
297
302
  }
@@ -439,6 +444,8 @@ export function createTickRunner(deps) {
439
444
  return { status: 'idle' };
440
445
  }
441
446
  const runId = `run-${candidate.issue.number}-${deps.clock.now().getTime()}`;
447
+ const lease = createRunLease({ clock: deps.clock, ownerInstanceId });
448
+ const workerIdentity = currentProcessIdentity();
442
449
  const runningRecord = {
443
450
  schemaVersion: 1,
444
451
  runId,
@@ -450,6 +457,9 @@ export function createTickRunner(deps) {
450
457
  status: 'running',
451
458
  startedAt: nowIso,
452
459
  routing,
460
+ lease,
461
+ workerPid: workerIdentity.pid,
462
+ workerProcessStartedAt: workerIdentity.processStartedAt,
453
463
  };
454
464
  await deps.stateStore.writeRunRecord(runningRecord);
455
465
  async function transitionRunLifecycle(lifecycle) {
@@ -491,6 +501,18 @@ export function createTickRunner(deps) {
491
501
  workflowLabel: workflowLabelForWorkflowName(workflowName),
492
502
  occurredAt: eventStampNow(),
493
503
  }));
504
+ let leaseRenewalTimer;
505
+ function startLeaseRenewal() {
506
+ leaseRenewalTimer = setInterval(() => {
507
+ void renewRunLease({
508
+ stateStore: deps.stateStore,
509
+ runId,
510
+ leaseId: lease.leaseId,
511
+ ownerInstanceId,
512
+ clock: deps.clock,
513
+ });
514
+ }, runLeaseRenewalIntervalMs);
515
+ }
494
516
  try {
495
517
  await transitionRunLifecycle('PREPARING');
496
518
  const prepareResult = workspaceMode === 'branch'
@@ -521,6 +543,7 @@ export function createTickRunner(deps) {
521
543
  });
522
544
  const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
523
545
  await transitionRunLifecycle('RUNNING');
546
+ startLeaseRenewal();
524
547
  const runnerResult = await deps.runner.run({
525
548
  action,
526
549
  projection: candidate,
@@ -532,7 +555,21 @@ export function createTickRunner(deps) {
532
555
  ...(workspacePath === undefined ? {} : { workspacePath }),
533
556
  ...(mergeConflictDetected ? { mergeConflictDetected: true } : {}),
534
557
  ...(upstreamChanges === undefined ? {} : { upstreamChanges }),
558
+ onProcessStart: async (identity) => {
559
+ await deps.stateStore.updateRunRecordIf(runId, {
560
+ expect: (record) => record.status === 'running' &&
561
+ record.lease?.leaseId === lease.leaseId &&
562
+ record.lease.ownerInstanceId === ownerInstanceId,
563
+ update: (record) => ({
564
+ ...record,
565
+ agentPid: identity.pid,
566
+ agentProcessStartedAt: identity.processStartedAt,
567
+ }),
568
+ });
569
+ },
535
570
  });
571
+ clearInterval(leaseRenewalTimer);
572
+ leaseRenewalTimer = undefined;
536
573
  const parsedRunnerResult = parseRunnerResult(runnerResult.result);
537
574
  const rawSentinel = parsedRunnerResult.status;
538
575
  // Coerce DONE → AWAITING_APPROVAL when the stage requires human sign-off.
@@ -709,6 +746,7 @@ export function createTickRunner(deps) {
709
746
  };
710
747
  }
711
748
  catch (err) {
749
+ clearInterval(leaseRenewalTimer);
712
750
  const finishedAt = deps.clock.now().toISOString();
713
751
  const sentinel = 'FAILED';
714
752
  const failedRecord = (await deps.stateStore.readRunRecord(runId)) ?? runningRecord;
@@ -301,6 +301,13 @@ const runTokenUsageSchema = z.object({
301
301
  costUsd: z.number().nonnegative().optional(),
302
302
  turns: z.number().nonnegative().optional(),
303
303
  });
304
+ const runLeaseSchema = z.object({
305
+ leaseId: z.string(),
306
+ ownerInstanceId: z.string(),
307
+ acquiredAt: isoTimestampSchema,
308
+ lastRenewedAt: isoTimestampSchema,
309
+ expiresAt: isoTimestampSchema,
310
+ });
304
311
  function legacyRunLifecycle(input) {
305
312
  if (input.lifecycle !== undefined) {
306
313
  return input;
@@ -346,6 +353,11 @@ export const runRecordSchema = z.preprocess((input) => {
346
353
  workflowOutcome: workflowOutcomeSchema.optional(),
347
354
  summary: z.string().optional(),
348
355
  routing: runnerRoutingSchema.optional(),
356
+ lease: runLeaseSchema.optional(),
357
+ workerPid: z.number().int().positive().optional(),
358
+ workerProcessStartedAt: z.string().optional(),
359
+ agentPid: z.number().int().positive().optional(),
360
+ agentProcessStartedAt: z.string().optional(),
349
361
  tokenUsage: runTokenUsageSchema.optional(),
350
362
  metadata: z.record(z.string(), z.unknown()).optional(),
351
363
  }));
@@ -156,11 +156,11 @@ export async function acquireFileLock(path, options) {
156
156
  }
157
157
  catch (error) {
158
158
  if (error.code === 'EEXIST') {
159
- if (options?.staleAfterMs !== undefined &&
160
- !(await readFileLockStatus(path, {
161
- staleAfterMs: options.staleAfterMs,
162
- now: options.now ?? new Date(),
163
- })).active) {
159
+ const status = await readFileLockStatus(path, {
160
+ ...(options?.staleAfterMs === undefined ? {} : { staleAfterMs: options.staleAfterMs }),
161
+ now: options?.now ?? new Date(),
162
+ });
163
+ if (!status.active) {
164
164
  await rm(path, { force: true });
165
165
  try {
166
166
  return await tryAcquire();
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const currentProcessStartedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
3
+ function readLinuxProcessStartTicks(pid) {
4
+ try {
5
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
6
+ const endCommand = stat.lastIndexOf(')');
7
+ if (endCommand === -1) {
8
+ return undefined;
9
+ }
10
+ const fields = stat
11
+ .slice(endCommand + 2)
12
+ .trim()
13
+ .split(/\s+/);
14
+ const startTicks = fields[19];
15
+ return startTicks === undefined ? undefined : `linux-start-ticks:${startTicks}`;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ export function readProcessIdentity(pid) {
22
+ if (!Number.isInteger(pid) || pid <= 0) {
23
+ return null;
24
+ }
25
+ try {
26
+ process.kill(pid, 0);
27
+ }
28
+ catch (error) {
29
+ if (error.code === 'ESRCH') {
30
+ return null;
31
+ }
32
+ }
33
+ const linuxStartedAt = readLinuxProcessStartTicks(pid);
34
+ if (linuxStartedAt !== undefined) {
35
+ return { pid, processStartedAt: linuxStartedAt };
36
+ }
37
+ if (pid === process.pid) {
38
+ return { pid, processStartedAt: currentProcessStartedAt };
39
+ }
40
+ return null;
41
+ }
42
+ export function currentProcessIdentity() {
43
+ return (readProcessIdentity(process.pid) ?? {
44
+ pid: process.pid,
45
+ processStartedAt: currentProcessStartedAt,
46
+ });
47
+ }
48
+ export function processIdentityMatches(input) {
49
+ if (input.pid === undefined || input.processStartedAt === undefined) {
50
+ return false;
51
+ }
52
+ const current = readProcessIdentity(input.pid);
53
+ return current?.processStartedAt === input.processStartedAt;
54
+ }
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "gf58d7a3";
127
+ export const wakeVersion = "gfb459ce";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.29",
3
+ "version": "0.2.30",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {