start-command 0.42.0 → 0.44.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 (36) hide show
  1. package/.changeset/README.md +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/README.md +2 -0
  4. package/package.json +6 -6
  5. package/src/lib/args-parser-queries.js +64 -3
  6. package/src/lib/docker-inspect-batch.js +3 -0
  7. package/src/lib/execution-attach.js +10 -1
  8. package/src/lib/execution-cleanup.js +8 -0
  9. package/src/lib/execution-control.js +14 -3
  10. package/src/lib/execution-pause.js +701 -0
  11. package/src/lib/execution-recovery.js +6 -0
  12. package/src/lib/execution-resume-all.js +14 -0
  13. package/src/lib/execution-resume.js +16 -1
  14. package/src/lib/execution-store.js +5 -1
  15. package/src/lib/formal-ai-client.js +8 -5
  16. package/src/lib/formal-ai-files.js +474 -61
  17. package/src/lib/formal-ai.js +170 -7
  18. package/src/lib/log-sanitizer.js +5 -0
  19. package/src/lib/query-commands.js +33 -1
  20. package/src/lib/session-probe.js +27 -1
  21. package/src/lib/status-formatter.js +13 -3
  22. package/src/lib/status-probe.js +19 -5
  23. package/src/lib/usage.js +8 -1
  24. package/test/args-parser-attach-resume.js +1 -1
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/fixtures/formal-ai/receipts-258.json +27 -9
  28. package/test/fixtures/formal-ai/receipts-261.json +589 -0
  29. package/test/fixtures/formal-ai/replay.cjs +4 -1
  30. package/test/fixtures/pause-golden.cjs +142 -0
  31. package/test/regression-243.js +4 -1
  32. package/test/regression-258-files.js +27 -5
  33. package/test/regression-258.js +22 -9
  34. package/test/regression-261.js +856 -0
  35. package/test/regression-263.js +867 -0
  36. package/test/shared-golden.js +28 -3
@@ -9,6 +9,11 @@
9
9
  * protocol error or cancellation stops it, and the exit status says which.
10
10
  * A final answer that is a known capability gap (issue #258) is reported as
11
11
  * incomplete with its obligations, never as an answer.
12
+ *
13
+ * A failed change stays unresolved until the same typed tool changes the same
14
+ * target and its readback is verified (issue #261): a later read, a copy over
15
+ * the file or Formal AI's final answer does not resolve it, and the run then
16
+ * ends `incomplete` with the original failure and its exit status.
12
17
  */
13
18
 
14
19
  const policy = require('./formal-ai-policy');
@@ -74,12 +79,21 @@ async function runFormalAiTask(options) {
74
79
  observedDirs: new Set(),
75
80
  contents: new Map(),
76
81
  };
82
+ // Failed calls whose obligation no later verified call met (#261).
83
+ const unresolved = [];
77
84
  const finish = (status, exitCode, message, remaining = [], extra = {}) => {
85
+ const open = unresolved.map(failureObligation);
78
86
  evidence.outcome = {
79
87
  status,
80
88
  exitCode,
81
89
  message,
82
- remainingObligations: remaining,
90
+ remainingObligations: [
91
+ ...remaining,
92
+ ...open.filter((item) => !remaining.includes(item)),
93
+ ],
94
+ ...(unresolved.length
95
+ ? { failures: unresolved.map((f) => ({ ...f })) }
96
+ : {}),
83
97
  ...extra,
84
98
  };
85
99
  return evidence;
@@ -152,11 +166,29 @@ async function runFormalAiTask(options) {
152
166
  return finish(
153
167
  'capability-gap',
154
168
  EXIT.UNSUPPORTED,
155
- describeGap(gap),
169
+ describeGap(gap, evidence.effects),
156
170
  gapObligations(gap, bounds.effects),
157
171
  { gap }
158
172
  );
159
173
  }
174
+ if (unresolved.length) {
175
+ const [first] = unresolved;
176
+ return finish(
177
+ 'incomplete',
178
+ first.exitCode || 1,
179
+ `\`${first.command}\` failed (${first.error}) and no later call resolved it; ${describeEffects(evidence.effects)}`
180
+ );
181
+ }
182
+ const record = unrecognizedGapRecord(response.content, messages);
183
+ if (record) {
184
+ return finish(
185
+ 'incomplete',
186
+ EXIT.UNSUPPORTED,
187
+ `Formal AI reported ${record.schema}${record.reason ? ` (${record.reason})` : ''}${record.target ? ` for ${record.target}` : ''}${record.clause ? ` for the clause ${JSON.stringify(record.clause)}` : ''}; nothing was verified as done by Formal AI; ${describeEffects(evidence.effects)}`,
188
+ ['Formal AI did not confirm the requested change'],
189
+ { gapRecord: record }
190
+ );
191
+ }
160
192
  if (response.handler === 'unknown' && toolCalls === 0) {
161
193
  return finish(
162
194
  'unsupported',
@@ -218,6 +250,7 @@ async function runFormalAiTask(options) {
218
250
  note,
219
251
  evidence,
220
252
  });
253
+ trackFailures(unresolved, evidence.steps[evidence.steps.length - 1]);
221
254
  if (signal && signal.aborted) {
222
255
  return cancelled();
223
256
  }
@@ -240,12 +273,138 @@ async function runFormalAiTask(options) {
240
273
  );
241
274
  }
242
275
 
243
- /** Capability gap, in words. */
244
- function describeGap(gap) {
276
+ /**
277
+ * Keep the failed calls of a run until a later call meets their obligation
278
+ * (issue #261). A failed edit or write is resolved only by a verified edit or
279
+ * write of the same target (a write also resolves a failed read of the file
280
+ * it created); a failed read by a read of the same target; a failed write
281
+ * command only by the same command succeeding. A copy or move over the target
282
+ * never counts as the edit.
283
+ */
284
+ function trackFailures(unresolved, step) {
285
+ if (!step || step.kind !== 'tool' || step.decision !== 'allowed') {
286
+ return;
287
+ }
288
+ const typed = step.tool !== undefined;
289
+ const changed = typed && step.tool !== 'read';
290
+ if (step.exitCode !== 0 && (typed || step.effect === 'write')) {
291
+ unresolved.push({
292
+ command: step.command,
293
+ ...(typed ? { tool: step.tool, target: step.target } : {}),
294
+ exitCode: step.exitCode ?? 1,
295
+ error:
296
+ step.error ||
297
+ (step.signal
298
+ ? `stopped by ${step.signal}`
299
+ : `exited with ${step.exitCode}`),
300
+ });
301
+ return;
302
+ }
303
+ if (step.exitCode !== 0) {
304
+ return;
305
+ }
306
+ const verified = changed && step.verification && step.verification.verified;
307
+ for (let index = unresolved.length - 1; index >= 0; index--) {
308
+ const failure = unresolved[index];
309
+ const resolved = typed
310
+ ? failure.target === step.target &&
311
+ (failure.tool === 'read'
312
+ ? step.tool === 'read' || verified
313
+ : verified && (failure.tool === step.tool || step.tool === 'edit'))
314
+ : failure.tool === undefined && failure.command === step.command;
315
+ if (resolved) {
316
+ unresolved.splice(index, 1);
317
+ }
318
+ }
319
+ }
320
+
321
+ function failureObligation(failure) {
322
+ return `\`${failure.command}\` failed: ${failure.error}`;
323
+ }
324
+
325
+ /**
326
+ * A final answer that is exactly one Links Notation record whose record_type
327
+ * names a gap, as Formal AI's task obligations answer (`obligation_gap`, e.g.
328
+ * reason `no_artifact_in_clause`): an id line, then ` key "value"` lines.
329
+ * Returned in the JSON gap shape (schema = record_type, never complete), so
330
+ * a gap Formal AI states in its own notation is never taken as success, even
331
+ * when start verified every change it made (#261).
332
+ * @param {string} text - Trimmed final answer
333
+ * @returns {object|null} {schema, reason?, clause?, complete: false}
334
+ */
335
+ function linoGapRecord(text) {
336
+ const [id, ...lines] = text.split('\n');
337
+ if (!/^[A-Za-z0-9_]+$/.test(id) || !lines.length) {
338
+ return null;
339
+ }
340
+ const fields = {};
341
+ for (const line of lines) {
342
+ const match = /^ {2}([a-z_]+) (?:"([^"]*)"|'([^']*)'|(\S+))$/.exec(line);
343
+ if (!match || Object.hasOwn(fields, match[1])) {
344
+ return null;
345
+ }
346
+ fields[match[1]] = match[2] ?? match[3] ?? match[4];
347
+ }
348
+ if (typeof fields.record_type !== 'string') {
349
+ return null;
350
+ }
351
+ return {
352
+ schema: fields.record_type,
353
+ ...(fields.reason ? { reason: fields.reason } : {}),
354
+ ...(fields.clause ? { clause: fields.clause } : {}),
355
+ complete: false,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * A final answer that is a JSON record of a gap start does not know: it is
361
+ * never taken as success, though its schema grants no recognized gap.
362
+ */
363
+ function unrecognizedGapRecord(content, messages) {
364
+ let value;
365
+ try {
366
+ value = JSON.parse(String(content || '').trim());
367
+ } catch {
368
+ value = linoGapRecord(String(content || '').trim());
369
+ }
370
+ if (
371
+ !value ||
372
+ typeof value !== 'object' ||
373
+ Array.isArray(value) ||
374
+ typeof value.schema !== 'string' ||
375
+ !/gap/i.test(value.schema) ||
376
+ !(
377
+ value.verified === false ||
378
+ value.complete === false ||
379
+ value.completed === false
380
+ )
381
+ ) {
382
+ return null;
383
+ }
384
+ if (files.echoedByTool(String(content).trim(), messages)) {
385
+ return null;
386
+ }
387
+ return {
388
+ schema: value.schema,
389
+ ...(typeof value.target === 'string' ? { target: value.target } : {}),
390
+ ...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
391
+ ...(typeof value.clause === 'string' ? { clause: value.clause } : {}),
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Capability gap, in words. When tools changed the working directory, the
397
+ * message keeps what start observed: Formal AI's gap still means the task is
398
+ * not confirmed, whatever start verified on its own (#261).
399
+ */
400
+ function describeGap(gap, effects) {
245
401
  const missing = gap.missingCapabilities.length
246
402
  ? `; missing ${gap.missingCapabilities.join(', ')}`
247
403
  : '';
248
- return `Formal AI reported a capability gap for ${gap.target} (${gap.reason}${missing}); nothing was verified as done`;
404
+ const head = `Formal AI reported a capability gap for ${gap.target} (${gap.reason}${missing})`;
405
+ return effects && policy.hasEffects(effects)
406
+ ? `${head}; Formal AI did not confirm the task as done; start observed: ${describeEffects(effects)}`
407
+ : `${head}; nothing was verified as done`;
249
408
  }
250
409
 
251
410
  function gapObligations(gap, effects) {
@@ -331,6 +490,7 @@ function runFileToolCall({ call, context, cwd, bounds, io, note, evidence }) {
331
490
  ? { previousSha256: receipt.previousSha256 }
332
491
  : {}),
333
492
  ...(receipt.replacements ? { replacements: receipt.replacements } : {}),
493
+ ...(receipt.mutation ? { mutation: receipt.mutation } : {}),
334
494
  });
335
495
  recordEffects(step, diff, evidence);
336
496
  if (receipt.error) {
@@ -347,14 +507,17 @@ function runFileToolCall({ call, context, cwd, bounds, io, note, evidence }) {
347
507
  stop: [
348
508
  'unverified',
349
509
  EXIT.PROTOCOL,
350
- `\`${call.command}\` wrote the file but ${receipt.verification.detail}`,
510
+ `\`${call.command}\` ran but ${receipt.verification.detail}`,
351
511
  ['the change could not be confirmed by readback'],
352
512
  ],
353
513
  };
354
514
  }
355
515
  note(receipt.verification.detail);
356
516
  }
357
- return { exitCode: receipt.exitCode, result: receipt.result };
517
+ return {
518
+ exitCode: receipt.exitCode,
519
+ result: receipt.result,
520
+ };
358
521
  }
359
522
 
360
523
  /** Show what a read returned, like the stdout of `cat`, within the output cap. */
@@ -12,6 +12,11 @@
12
12
  * `Authorization: <scheme> <value>` header, so a logged one-line JSON prompt
13
13
  * keeps the rest of its line (issue #249, sensitive-data-sanitizer#45); the
14
14
  * pre-pass that did this here is gone (issue #253).
15
+ *
16
+ * The package stays on ^0.14.0: 0.20.0 and later drop the bytes after a
17
+ * credential that is followed by a NUL byte (sensitive-data-sanitizer#180,
18
+ * the blocker in .github/dependency-blockers.json). The Rust installer and its
19
+ * version check require the same release (rust/src/lib/log_sanitizer.rs).
15
20
  */
16
21
 
17
22
  const fs = require('fs');
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Every option in this module addresses an execution that already exists in
5
5
  * the tracking store instead of launching a new one: --status, --list,
6
- * --upload-log, --stop, --terminate, --attach, --resume, --resume-all and
6
+ * --upload-log, --stop, --terminate, --pause, --unpause, --attach, --resume,
7
+ * --resume-all and
7
8
  * --cleanup, plus --alias and --rename. `dispatchQueryCommand` is the single
8
9
  * entry point the CLI calls before it starts interpreting the command line as
9
10
  * a command to run.
@@ -54,6 +55,28 @@ function handleControlQuery(store, identifier, action) {
54
55
  return reportResult(controlExecution(store, identifier, action));
55
56
  }
56
57
 
58
+ /**
59
+ * Handle --pause / --unpause (issue #263).
60
+ * @param {?object} store - Execution store
61
+ * @param {string} identifier - UUID or session name
62
+ * @param {string} action - pause or unpause
63
+ * @param {object} [options] - {outputFormat, pauseReason, pausedBy}
64
+ * @returns {number} Exit code
65
+ */
66
+ function handlePauseQuery(store, identifier, action, options = {}) {
67
+ const pause = require('./execution-pause');
68
+ const deps = {
69
+ outputFormat: options.outputFormat,
70
+ reason: options.pauseReason || null,
71
+ by: options.pausedBy || null,
72
+ };
73
+ return reportResult(
74
+ action === pause.PauseAction.PAUSE
75
+ ? pause.pauseExecution(store, identifier, deps)
76
+ : pause.unpauseExecution(store, identifier, deps)
77
+ );
78
+ }
79
+
57
80
  function handleAttachQuery(store, identifier, readOnly) {
58
81
  return reportResult(attachExecution(store, identifier, { readOnly }));
59
82
  }
@@ -155,6 +178,8 @@ function hasQueryMode(options) {
155
178
  hasValue(options.uploadLog) ||
156
179
  hasValue(options.stop) ||
157
180
  hasValue(options.terminate) ||
181
+ hasValue(options.pause) ||
182
+ hasValue(options.unpause) ||
158
183
  hasValue(options.attach) ||
159
184
  hasValue(options.resume) ||
160
185
  options.resumeAll === true ||
@@ -193,6 +218,12 @@ async function dispatchQueryCommand(options, context = {}) {
193
218
  ControlAction.TERMINATE
194
219
  );
195
220
  }
221
+ if (hasValue(options.pause)) {
222
+ return handlePauseQuery(store, options.pause, 'pause', options);
223
+ }
224
+ if (hasValue(options.unpause)) {
225
+ return handlePauseQuery(store, options.unpause, 'unpause', options);
226
+ }
196
227
  if (hasValue(options.attach)) {
197
228
  return handleAttachQuery(store, options.attach, options.readOnly === true);
198
229
  }
@@ -234,6 +265,7 @@ module.exports = {
234
265
  handleCleanup,
235
266
  handleControlQuery,
236
267
  handleListQuery,
268
+ handlePauseQuery,
237
269
  handleResumeAllQuery,
238
270
  handleResumeQuery,
239
271
  handleStatusQuery,
@@ -13,12 +13,16 @@ const { getDockerCommand } = require('./docker-cleanup');
13
13
  /**
14
14
  * States a tracked isolation session can be observed in.
15
15
  * - RUNNING: the session/container is alive and can be attached to
16
+ * - PAUSED: the container is frozen by `docker pause` (issue #263); it is
17
+ * alive (its process and memory are kept) but can be neither attached to nor
18
+ * resumed until `$ --unpause` thaws it
16
19
  * - STOPPED: the container still exists but is not running (resumable)
17
20
  * - MISSING: no trace of the session/container remains
18
21
  * - UNKNOWN: the backend cannot be probed locally (e.g. ssh)
19
22
  */
20
23
  const SessionState = {
21
24
  RUNNING: 'running',
25
+ PAUSED: 'paused',
22
26
  STOPPED: 'stopped',
23
27
  MISSING: 'missing',
24
28
  UNKNOWN: 'unknown',
@@ -39,6 +43,11 @@ function mapDockerStatusToState(status) {
39
43
  if (normalized === 'running' || normalized === 'restarting') {
40
44
  return SessionState.RUNNING;
41
45
  }
46
+ if (normalized === 'paused') {
47
+ // Paused is alive, not stopped: reading it as stopped made `--resume`
48
+ // plan a `docker start`/snapshot of a frozen container (issue #263).
49
+ return SessionState.PAUSED;
50
+ }
42
51
  return SessionState.STOPPED;
43
52
  }
44
53
 
@@ -109,13 +118,30 @@ function probeSession(record, runner = runCommand) {
109
118
  probe.state = result.success ? SessionState.RUNNING : SessionState.MISSING;
110
119
  }
111
120
 
112
- probe.alive = probe.state === SessionState.RUNNING;
121
+ probe.alive =
122
+ probe.state === SessionState.RUNNING || probe.state === SessionState.PAUSED;
113
123
  return probe;
114
124
  }
115
125
 
126
+ /**
127
+ * Refusal for a control that cannot act on a paused container (issue #263).
128
+ * @param {string} action - What was refused, e.g. "attach to" or "resume"
129
+ * @param {string} uuid - Execution UUID, for the suggested command
130
+ * @param {string} sessionName - Container name
131
+ * @returns {{error: string}} Error result
132
+ */
133
+ function pausedSessionError(action, uuid, sessionName) {
134
+ return {
135
+ error:
136
+ `Session "${sessionName}" is paused (frozen by docker pause), so start cannot ${action} it. ` +
137
+ `Use \`$ --unpause ${uuid}\` first to continue the same process, or \`$ --stop ${uuid}\` to end it.`,
138
+ };
139
+ }
140
+
116
141
  module.exports = {
117
142
  SessionState,
118
143
  mapDockerStatusToState,
119
144
  parseScreenSessionState,
145
+ pausedSessionError,
120
146
  probeSession,
121
147
  };
@@ -33,6 +33,7 @@ const {
33
33
  persistContainerRemoval,
34
34
  } = require('./container-removal');
35
35
  const { withDockerInspectBatch } = require('./docker-inspect-batch');
36
+ const pause = require('./execution-pause');
36
37
  const {
37
38
  backendExitCode,
38
39
  dockerProbeSpawnOptions,
@@ -460,8 +461,11 @@ function enrichDetachedStatus(record) {
460
461
  ? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
461
462
  : null;
462
463
  const probe = dockerProbe();
463
- const resolved = attachContainerRemoval(
464
- resolveDetachedStatus(record, logTail, probe),
464
+ const resolved = pause.attachPauseState(
465
+ attachContainerRemoval(
466
+ resolveDetachedStatus(record, logTail, probe),
467
+ probe
468
+ ),
465
469
  probe
466
470
  );
467
471
  const enriched = attachMemoryExhaustion(
@@ -664,6 +668,7 @@ function formatRecordAsText(record) {
664
668
  if (obj.staleDetectedAt) {
665
669
  lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
666
670
  }
671
+ lines.push(...pause.pauseTextLines(obj));
667
672
  if (obj.containerState) {
668
673
  // The container no longer exists; `containerId` is history (issue #225).
669
674
  lines.push(`Container State: ${obj.containerState}`);
@@ -821,6 +826,7 @@ function queryStatus(store, identifier, outputFormat) {
821
826
  () => {
822
827
  const enrichedRecord = enrichDetachedStatus(record);
823
828
  persistContainerRemoval(store, record, enrichedRecord);
829
+ pause.persistPauseReconciliation(store, enrichedRecord);
824
830
  return attachProcessIds(enrichedRecord);
825
831
  }
826
832
  );
@@ -856,7 +862,11 @@ function listExecutions(store, outputFormat, options = {}) {
856
862
  // One docker inspect for every listed container (issue #243).
857
863
  const enriched = sortRecordsByStartTimeDesc(
858
864
  withDockerInspectBatch(all, dockerProbe(), () =>
859
- all.map(prepareRecordForList)
865
+ all.map((record) => {
866
+ const prepared = prepareRecordForList(record);
867
+ pause.persistPauseReconciliation(store, prepared);
868
+ return prepared;
869
+ })
860
870
  )
861
871
  );
862
872
  // --running reports the reconciled status, not the stored one: a record
@@ -29,7 +29,8 @@ const {
29
29
  *
30
30
  * @param {string} sessionName - Container name
31
31
  * @param {Object|null} [probe] - Receives `sessionNotFound` (issue #225)
32
- * @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null}|null} State,
32
+ * @returns {{running: boolean, exitCode: number|null, oomKilled: boolean|null,
33
+ * status: ?string, paused: boolean}|null} State,
33
34
  * or null when the container cannot be inspected (not found yet, removed, or docker error)
34
35
  */
35
36
  function inspectDockerState(sessionName, probe = null) {
@@ -37,7 +38,8 @@ function inspectDockerState(sessionName, probe = null) {
37
38
  getDockerCommand(),
38
39
  dockerProbeSpawnOptions(),
39
40
  '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
40
- '{{.State.StartedAt}} {{.State.FinishedAt}}',
41
+ '{{.State.StartedAt}} {{.State.FinishedAt}} ' +
42
+ '{{.State.Status}} {{.State.Paused}}',
41
43
  sessionName
42
44
  );
43
45
  if (probe) {
@@ -50,9 +52,15 @@ function inspectDockerState(sessionName, probe = null) {
50
52
  }
51
53
  // Parsed positionally and defensively: an older docker (or a stubbed one)
52
54
  // may answer with fewer fields than the template asks for.
53
- const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] = stdout
54
- .trim()
55
- .split(/\s+/);
55
+ const [
56
+ runningRaw,
57
+ exitRaw,
58
+ oomKilledRaw,
59
+ startedRaw,
60
+ finishedRaw,
61
+ statusRaw,
62
+ pausedRaw,
63
+ ] = stdout.trim().split(/\s+/);
56
64
  const exitCode = Number.parseInt(exitRaw, 10);
57
65
  return {
58
66
  running: runningRaw === 'true',
@@ -61,6 +69,9 @@ function inspectDockerState(sessionName, probe = null) {
61
69
  oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
62
70
  startedAt: normalizeDockerTimestamp(startedRaw),
63
71
  finishedAt: normalizeDockerTimestamp(finishedRaw),
72
+ // A paused container is still `.State.Running` (issue #263).
73
+ status: statusRaw || null,
74
+ paused: pausedRaw === 'true' || statusRaw === 'paused',
64
75
  };
65
76
  }
66
77
 
@@ -99,6 +110,9 @@ function readDockerState(record, probe = null) {
99
110
  ) {
100
111
  return null;
101
112
  }
113
+ if (probe) {
114
+ probe.dockerState = state;
115
+ }
102
116
  return state;
103
117
  }
104
118
 
package/src/lib/usage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Print usage information */
2
2
  function printUsage() {
3
- console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all | $ --alias <id> <name> | $ --rename <id> <name>
3
+ console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --pause <id> | $ --unpause <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all | $ --alias <id> <name> | $ --rename <id> <name>
4
4
 
5
5
  Options:
6
6
  --help, -h Show this usage and exit successfully
@@ -54,6 +54,11 @@ Options:
54
54
  --no-sanitize With --upload-log, explicitly upload without secret redaction
55
55
  --stop <id> Ask a detached isolated execution to stop gracefully
56
56
  --terminate <id> Terminate a detached isolated execution immediately
57
+ --pause <id> Freeze a detached Docker execution (docker pause): CPU is
58
+ released, memory stays allocated (--stop frees it)
59
+ --pause-reason <text> With --pause, record why the execution was frozen
60
+ --paused-by <name> With --pause, record who froze the execution
61
+ --unpause <id> Thaw a paused Docker execution; the same process continues
57
62
  --attach <id> Attach to a running detached isolated execution
58
63
  --read-only With --attach, follow output without sending input
59
64
  --resume <id> Restart a stopped detached execution in the same environment
@@ -93,6 +98,8 @@ Examples:
93
98
  $ --upload-log my-screen-session # Upload stored execution log
94
99
  $ --stop my-screen-session # Ask detached execution to stop gracefully
95
100
  $ --terminate my-screen-session # Terminate detached execution immediately
101
+ $ --pause my-docker-session --pause-reason 'rate limit' # Freeze, keep RAM
102
+ $ --unpause my-docker-session # Continue the same frozen process
96
103
  $ --list --running # Only executions that are still running
97
104
  $ --attach my-docker-session # Re-enter a running detached session
98
105
  $ --attach my-docker-session --read-only # Follow its output without stdin
@@ -142,7 +142,7 @@ describe('query mode exclusivity', () => {
142
142
  test(`rejects ${args.join(' ')}`, () => {
143
143
  assert.throws(() => {
144
144
  parse(args);
145
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
145
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --pause, --unpause, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
146
146
  });
147
147
  }
148
148
  });
@@ -54,19 +54,19 @@ describe('control options', () => {
54
54
  it('should reject combining query and control modes', () => {
55
55
  assert.throws(() => {
56
56
  parseArgs(['--status', 'uuid-here', '--stop', 'my-session']);
57
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
57
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --pause, --unpause, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
58
58
  });
59
59
 
60
60
  it('should reject combining upload-log with control modes', () => {
61
61
  assert.throws(() => {
62
62
  parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']);
63
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
63
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --pause, --unpause, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
64
64
  });
65
65
 
66
66
  it('should reject output-format with control modes', () => {
67
67
  assert.throws(() => {
68
68
  parseArgs(['--stop', 'my-session', '--output-format', 'json']);
69
- }, /--output-format option is only valid with --status, --list, --resume, or --resume-all/);
69
+ }, /--output-format option is only valid with --status, --list, --resume, --resume-all, --pause, or --unpause/);
70
70
  });
71
71
 
72
72
  it('should default control options to null', () => {
@@ -859,7 +859,7 @@ describe('output-format option', () => {
859
859
  it('should throw error for output-format without status', () => {
860
860
  assert.throws(() => {
861
861
  parseArgs(['--output-format', 'json', '--', 'npm', 'test']);
862
- }, /--output-format option is only valid with --status, --list, --resume, or --resume-all/);
862
+ }, /--output-format option is only valid with --status, --list, --resume, --resume-all, --pause, or --unpause/);
863
863
  });
864
864
 
865
865
  it('should accept all valid output formats', () => {
@@ -1,6 +1,6 @@
1
1
  {
2
- "source": "captured from Formal AI 0.355.29 (982a7ffcb05d039292712ec9fe33bdbd645ed569) js agent mode by experiments/issue-258/capture-receipts.cjs",
3
- "capturedAt": "2026-11-02",
2
+ "source": "captured from Formal AI 0.355.32 (08ca27d1ee0560fb56450ad798f3a79c684404ed) js agent mode by experiments/issue-258/capture-receipts.cjs",
3
+ "capturedAt": "2026-11-04",
4
4
  "fixture": {
5
5
  "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
6
6
  },
@@ -136,10 +136,21 @@
136
136
  }
137
137
  ],
138
138
  "outcome": {
139
- "status": "completed",
139
+ "status": "incomplete",
140
140
  "exitCode": 1,
141
- "message": "finished; the last tool call exited with 1; no file changed",
142
- "remainingObligations": ["the last tool call failed"]
141
+ "message": "`edit snippet.mjs` failed (snippet.mjs: old_string was not found; nothing changed) and no later call resolved it; no file changed",
142
+ "remainingObligations": [
143
+ "`edit snippet.mjs` failed: snippet.mjs: old_string was not found; nothing changed"
144
+ ],
145
+ "failures": [
146
+ {
147
+ "command": "edit snippet.mjs",
148
+ "tool": "edit",
149
+ "target": "snippet.mjs",
150
+ "exitCode": 1,
151
+ "error": "snippet.mjs: old_string was not found; nothing changed"
152
+ }
153
+ ]
143
154
  },
144
155
  "after": {
145
156
  "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"before\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"
@@ -322,10 +333,17 @@
322
333
  }
323
334
  ],
324
335
  "outcome": {
325
- "status": "completed",
326
- "exitCode": 0,
327
- "message": "finished after 3 tool call(s); changed snippet.mjs",
328
- "remainingObligations": []
336
+ "status": "incomplete",
337
+ "exitCode": 65,
338
+ "message": "Formal AI reported obligation_gap (no_artifact_in_clause) for the clause \"Preserve all other content.\"; nothing was verified as done by Formal AI; changed snippet.mjs",
339
+ "remainingObligations": [
340
+ "Formal AI did not confirm the requested change"
341
+ ],
342
+ "gapRecord": {
343
+ "schema": "obligation_gap",
344
+ "reason": "no_artifact_in_clause",
345
+ "clause": "Preserve all other content."
346
+ }
329
347
  },
330
348
  "after": {
331
349
  "snippet.mjs": "// Synthetic snippet for issue #258: keep this comment.\nconst label = \"after\";\n\n\texport function show(name) { \n return `${label}: ${name}`; // trailing comment\n}\n/* label = \"before\" in a comment stays */\n"