@atolis-hq/wake 0.2.13 → 0.2.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.
@@ -96,6 +96,22 @@ async function handleRequest(req, res, options, now) {
96
96
  sendJson(res, 202, request);
97
97
  return;
98
98
  }
99
+ if (req.method === 'POST' &&
100
+ resource === 'runners' &&
101
+ segments.length === 3 &&
102
+ segments[2] === 'unpause') {
103
+ const runnerName = segments[1];
104
+ const ledger = await stateStore.readLedger();
105
+ const existingRunners = ledger?.runners ?? {};
106
+ if (existingRunners[runnerName] !== undefined) {
107
+ await stateStore.writeLedger({
108
+ schemaVersion: 1,
109
+ runners: { ...existingRunners, [runnerName]: { failureCount: 0 } },
110
+ });
111
+ }
112
+ sendJson(res, 200, { runnerName, unpaused: true });
113
+ return;
114
+ }
99
115
  if (req.method !== 'GET') {
100
116
  sendJson(res, 405, { error: `method not allowed for ${url.pathname}` });
101
117
  return;
@@ -148,6 +148,9 @@ export async function runSandboxCommand(input) {
148
148
  await runStopCommand({
149
149
  args: input.args.slice(1),
150
150
  stateStore: input.stateStore,
151
+ ...(input.recoverActiveRuns === undefined
152
+ ? {}
153
+ : { recoverActiveRuns: input.recoverActiveRuns }),
151
154
  docker: input.docker,
152
155
  containerName: input.config.sandbox.containerName,
153
156
  sleep: input.sleep,
@@ -174,6 +177,9 @@ export async function runSandboxCommand(input) {
174
177
  imageRepository: input.config.sandbox.imageRepository,
175
178
  containerName: input.config.sandbox.containerName,
176
179
  stateStore: input.stateStore,
180
+ ...(input.recoverActiveRuns === undefined
181
+ ? {}
182
+ : { recoverActiveRuns: input.recoverActiveRuns }),
177
183
  docker: input.docker,
178
184
  git: input.selfUpdate.git,
179
185
  issueReporter: input.selfUpdate.issueReporter,
@@ -72,6 +72,9 @@ export async function runSelfUpdateCommand(input) {
72
72
  }
73
73
  await waitForActiveRuns({
74
74
  listRunRecords: input.stateStore.listRunRecords,
75
+ ...(input.recoverActiveRuns === undefined
76
+ ? {}
77
+ : { recoverActiveRuns: input.recoverActiveRuns }),
75
78
  sleep: input.sleep,
76
79
  logger: input.logger,
77
80
  });
@@ -4,6 +4,7 @@ export async function waitForActiveRuns(input) {
4
4
  const pollIntervalMs = input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
5
5
  const startedAt = Date.now();
6
6
  for (;;) {
7
+ await input.recoverActiveRuns?.();
7
8
  const records = await input.listRunRecords();
8
9
  const activeRuns = records.filter((record) => record.status === 'running');
9
10
  if (activeRuns.length === 0) {
@@ -32,6 +33,9 @@ function readNumberFlag(name, args) {
32
33
  export async function runStopCommand(input) {
33
34
  await waitForActiveRuns({
34
35
  listRunRecords: input.stateStore.listRunRecords,
36
+ ...(input.recoverActiveRuns === undefined
37
+ ? {}
38
+ : { recoverActiveRuns: input.recoverActiveRuns }),
35
39
  sleep: input.sleep,
36
40
  pollIntervalMs: readNumberFlag('--poll-interval-ms', input.args),
37
41
  timeoutMs: readNumberFlag('--timeout-ms', input.args),
@@ -0,0 +1,39 @@
1
+ import { readFileLockStatus } from '../lib/lock.js';
2
+ import { maxConfiguredRunnerTimeoutMs } from '../domain/runner-routing.js';
3
+ import { createOutbox } from './outbox.js';
4
+ import { createProjectionUpdater } from './projection-updater.js';
5
+ import { createStaleRunReconciler } from './stale-run-reconciler.js';
6
+ export function createActiveRunRecovery(deps) {
7
+ const projectionUpdater = createProjectionUpdater({
8
+ stateStore: deps.stateStore,
9
+ resourceIndex: deps.resourceIndex,
10
+ config: deps.config,
11
+ });
12
+ const { deliverOutboundEvent } = createOutbox({
13
+ clock: deps.clock,
14
+ stateStore: deps.stateStore,
15
+ projectionUpdater,
16
+ });
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;
23
+ }
24
+ return Date.parse(lock.metadata.acquiredAt) <= Date.parse(record.startedAt);
25
+ }
26
+ const { reconcileStaleRunningRecords } = createStaleRunReconciler({
27
+ config: deps.config,
28
+ stateStore: deps.stateStore,
29
+ projectionUpdater,
30
+ runnerTimeoutMs: () => maxConfiguredRunnerTimeoutMs(deps.config),
31
+ isRunningRecordActive,
32
+ deliverOutboundEvent,
33
+ });
34
+ return {
35
+ recoverActiveRuns() {
36
+ return reconcileStaleRunningRecords(deps.clock.now());
37
+ },
38
+ };
39
+ }
@@ -8,21 +8,30 @@ import { createEventEnvelope } from '../lib/event-log.js';
8
8
  // `deliverOutboundEvent` is injected rather than imported so this module does
9
9
  // not depend on the outbox module directly.
10
10
  export function createStaleRunReconciler(deps) {
11
- function isStaleRunningRecord(record, now) {
11
+ async function staleReason(record, now) {
12
12
  if (record.status !== 'running') {
13
- return false;
13
+ return null;
14
+ }
15
+ if (deps.isRunningRecordActive !== undefined && !(await deps.isRunningRecordActive(record))) {
16
+ return 'runner-lock-not-active';
14
17
  }
15
18
  const startedAtMs = Date.parse(record.startedAt);
16
19
  if (!Number.isFinite(startedAtMs)) {
17
- return true;
20
+ return 'timeout';
18
21
  }
19
- return now.getTime() - startedAtMs >= deps.runnerTimeoutMs();
22
+ return now.getTime() - startedAtMs >= deps.runnerTimeoutMs() ? 'timeout' : null;
20
23
  }
21
24
  async function reconcileStaleRunningRecords(now) {
22
25
  const finishedAt = now.toISOString();
23
26
  const runRecords = await deps.stateStore.listRunRecords();
24
- const staleRecords = runRecords.filter((record) => isStaleRunningRecord(record, now));
25
- for (const record of staleRecords) {
27
+ const staleRecords = [];
28
+ for (const record of runRecords) {
29
+ const reason = await staleReason(record, now);
30
+ if (reason !== null) {
31
+ staleRecords.push({ record, reason });
32
+ }
33
+ }
34
+ for (const { record, reason } of staleRecords) {
26
35
  // Run records carry the work item they belong to, so this is a direct
27
36
  // O(1) read — no scan, no index, no source ambiguity. The record's
28
37
  // repo/issueNumber are representation content and take no part in it.
@@ -57,6 +66,7 @@ export function createStaleRunReconciler(deps) {
57
66
  metadata: {
58
67
  ...record.metadata,
59
68
  reconciledBy: 'stale-running-record',
69
+ staleReason: reason,
60
70
  timeoutMs: deps.runnerTimeoutMs(),
61
71
  },
62
72
  });
@@ -79,7 +89,7 @@ export function createStaleRunReconciler(deps) {
79
89
  action: record.action,
80
90
  sentinel: 'FAILED',
81
91
  runId: record.runId,
82
- reason: 'runner:stale-timeout',
92
+ reason: reason === 'timeout' ? 'runner:stale-timeout' : 'runner:orphaned-process',
83
93
  ...(record.routing === undefined ? {} : { routing: record.routing }),
84
94
  },
85
95
  });
@@ -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 } from '../lib/lock.js';
4
+ import { acquireFileLock, readFileLockStatus } 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';
@@ -58,6 +58,7 @@ export function createTickRunner(deps) {
58
58
  stateStore: deps.stateStore,
59
59
  projectionUpdater,
60
60
  runnerTimeoutMs,
61
+ isRunningRecordActive,
61
62
  deliverOutboundEvent,
62
63
  });
63
64
  const { cleanupClosedIssueWorkspaces } = createWorkspaceCleanup({
@@ -203,6 +204,15 @@ export function createTickRunner(deps) {
203
204
  function runnerTimeoutMs() {
204
205
  return maxConfiguredRunnerTimeoutMs(deps.config);
205
206
  }
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;
213
+ }
214
+ return Date.parse(lock.metadata.acquiredAt) <= Date.parse(record.startedAt);
215
+ }
206
216
  async function parkConfigDriftedProjections(projections) {
207
217
  let parked = false;
208
218
  for (const projection of projections) {
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { link, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -21,6 +22,7 @@ function parseLockMetadata(raw) {
21
22
  pid: record.pid,
22
23
  acquiredAt: record.acquiredAt,
23
24
  ...(typeof record.lockId === 'string' ? { lockId: record.lockId } : {}),
25
+ ...(typeof record.commandLine === 'string' ? { commandLine: record.commandLine } : {}),
24
26
  };
25
27
  }
26
28
  catch {
@@ -43,6 +45,28 @@ function isPidAlive(pid) {
43
45
  return true;
44
46
  }
45
47
  }
48
+ function readProcessCommandLine(pid) {
49
+ if (pid === process.pid) {
50
+ return process.argv.join(' ');
51
+ }
52
+ try {
53
+ return readFileSyncProcessCommandLine(pid);
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function readFileSyncProcessCommandLine(pid) {
60
+ const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
61
+ const commandLine = raw.replace(/\0/g, ' ').trim();
62
+ return commandLine.length === 0 ? undefined : commandLine;
63
+ }
64
+ function defaultProcessInspector() {
65
+ return {
66
+ isPidAlive,
67
+ readCommandLine: readProcessCommandLine,
68
+ };
69
+ }
46
70
  async function readLockMetadata(path) {
47
71
  try {
48
72
  return parseLockMetadata(await readFile(path, 'utf8'));
@@ -51,13 +75,48 @@ async function readLockMetadata(path) {
51
75
  return null;
52
76
  }
53
77
  }
54
- async function lockIsStale(path, staleAfterMs, now) {
78
+ export async function readFileLockStatus(path, options) {
55
79
  const metadata = await readLockMetadata(path);
56
80
  if (metadata === null) {
57
- return true;
81
+ try {
82
+ await readFile(path, 'utf8');
83
+ return { present: true, active: false, staleReason: 'invalid-metadata' };
84
+ }
85
+ catch {
86
+ return { present: false, active: false, staleReason: 'missing' };
87
+ }
58
88
  }
89
+ const now = options?.now ?? new Date();
59
90
  const ageMs = now.getTime() - Date.parse(metadata.acquiredAt);
60
- return ageMs >= staleAfterMs || !isPidAlive(metadata.pid);
91
+ if (options?.staleAfterMs !== undefined && ageMs >= options.staleAfterMs) {
92
+ return { present: true, active: false, metadata, ageMs, staleReason: 'expired' };
93
+ }
94
+ const inspector = options?.processInspector ?? defaultProcessInspector();
95
+ if (!inspector.isPidAlive(metadata.pid)) {
96
+ return { present: true, active: false, metadata, ageMs, staleReason: 'pid-not-alive' };
97
+ }
98
+ const commandLine = inspector.readCommandLine(metadata.pid);
99
+ if (commandLine !== undefined &&
100
+ ((metadata.commandLine !== undefined && commandLine !== metadata.commandLine) ||
101
+ (metadata.commandLine === undefined &&
102
+ options?.expectedCommandIncludes !== undefined &&
103
+ !options.expectedCommandIncludes.every((fragment) => commandLine.includes(fragment))))) {
104
+ return {
105
+ present: true,
106
+ active: false,
107
+ metadata,
108
+ ageMs,
109
+ commandLine,
110
+ staleReason: 'pid-command-mismatch',
111
+ };
112
+ }
113
+ return {
114
+ present: true,
115
+ active: true,
116
+ metadata,
117
+ ageMs,
118
+ ...(commandLine === undefined ? {} : { commandLine }),
119
+ };
61
120
  }
62
121
  export async function acquireFileLock(path, options) {
63
122
  await mkdir(dirname(path), { recursive: true });
@@ -65,6 +124,7 @@ export async function acquireFileLock(path, options) {
65
124
  pid: process.pid,
66
125
  acquiredAt: (options?.now ?? new Date()).toISOString(),
67
126
  lockId: randomUUID(),
127
+ commandLine: process.argv.join(' '),
68
128
  };
69
129
  async function tryAcquire() {
70
130
  const tempPath = join(dirname(path), `.tick-lock-${metadata.lockId}.tmp`);
@@ -97,7 +157,10 @@ export async function acquireFileLock(path, options) {
97
157
  catch (error) {
98
158
  if (error.code === 'EEXIST') {
99
159
  if (options?.staleAfterMs !== undefined &&
100
- (await lockIsStale(path, options.staleAfterMs, options.now ?? new Date()))) {
160
+ !(await readFileLockStatus(path, {
161
+ staleAfterMs: options.staleAfterMs,
162
+ now: options.now ?? new Date(),
163
+ })).active) {
101
164
  await rm(path, { force: true });
102
165
  try {
103
166
  return await tryAcquire();
package/dist/src/main.js CHANGED
@@ -28,6 +28,7 @@ import { runSandboxSetupCommand } from './cli/sandbox-setup-command.js';
28
28
  import { collectStartupPreflightFailures, runStartupPreflight } from './cli/startup-preflight.js';
29
29
  import { runUiCommand } from './cli/ui-command.js';
30
30
  import { loadWakeConfig } from './config/load-config.js';
31
+ import { createActiveRunRecovery } from './core/active-run-recovery.js';
31
32
  import { createControlPlane } from './core/control-plane.js';
32
33
  import { createOutboundSinkRouter, createWorkSourceFanIn } from './core/sink-router.js';
33
34
  import { createTickRunner } from './core/tick-runner.js';
@@ -842,6 +843,12 @@ async function main() {
842
843
  wakeRoot,
843
844
  });
844
845
  const docker = createHostDockerCli();
846
+ const recoverActiveRuns = createActiveRunRecovery({
847
+ clock: systemClock,
848
+ config,
849
+ stateStore,
850
+ resourceIndex: createResourceIndex({ paths: stateStore.paths }),
851
+ }).recoverActiveRuns;
845
852
  const repoRoot = config.dev?.repoRoot;
846
853
  const selfUpdate = commandArgs[0] === 'self-update' &&
847
854
  config.dev?.mode === 'source' &&
@@ -912,6 +919,7 @@ async function main() {
912
919
  docker,
913
920
  packagedTemplatesRoot: resolve(resolvePackageRoot(), 'docker'),
914
921
  stateStore,
922
+ recoverActiveRuns,
915
923
  sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)),
916
924
  logger: {
917
925
  info(message) {
@@ -1 +1 @@
1
- export const wakeVersion = "0.2.13+g3da36d3";
1
+ export const wakeVersion = "0.2.15+g36c8953";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {