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
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Issue #263: runs one `pause-control` shared golden case
3
+ * (parity/fixtures/contracts.json) through the JavaScript `--pause` /
4
+ * `--unpause` control path. rust/tests/shared_golden.rs runs the same cases
5
+ * through the Rust path, so both runtimes are held to identical reports,
6
+ * errors, docker argv and persisted records.
7
+ *
8
+ * The in-memory docker follows the rules spelled out in
9
+ * `pause-control.docker` of the fixture file; keep it in step with
10
+ * `GoldenDocker` in rust/tests/shared_golden.rs.
11
+ */
12
+
13
+ const TIMESTAMP =
14
+ /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g;
15
+
16
+ /** Replace every ISO-8601 timestamp, which differs per run, with a marker. */
17
+ function normalize(value) {
18
+ if (typeof value === 'string') {
19
+ return value.replace(TIMESTAMP, '<timestamp>');
20
+ }
21
+ if (Array.isArray(value)) {
22
+ return value.map(normalize);
23
+ }
24
+ if (value && typeof value === 'object') {
25
+ return Object.fromEntries(
26
+ Object.entries(value).map(([key, entry]) => [key, normalize(entry)])
27
+ );
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function goldenDocker(initial, name) {
33
+ const docker = { state: initial, calls: [] };
34
+ const ok = (stdout = '') => ({
35
+ success: true,
36
+ stdout,
37
+ stderr: '',
38
+ status: 0,
39
+ error: null,
40
+ });
41
+ const fail = (stderr) => ({
42
+ success: false,
43
+ stdout: '',
44
+ stderr,
45
+ status: 1,
46
+ error: null,
47
+ });
48
+ docker.runner = (command, args, extra = {}) => {
49
+ docker.calls.push({ args: [...args], bounded: Boolean(extra.timeout) });
50
+ const verb = args[0];
51
+ if (docker.state === 'removed' && verb !== 'rm') {
52
+ return fail(`Error response from daemon: No such container: ${name}`);
53
+ }
54
+ const alive = docker.state === 'running' || docker.state === 'paused';
55
+ switch (verb) {
56
+ case 'inspect': {
57
+ const format = args[args.indexOf('-f') + 1];
58
+ if (format === '{{.State.Status}} {{.State.Paused}}') {
59
+ return ok(`${docker.state} ${docker.state === 'paused'}\n`);
60
+ }
61
+ if (format === '{{.State.Status}}') {
62
+ return ok(`${docker.state}\n`);
63
+ }
64
+ if (format === '{{.Id}} {{.State.Pid}}') {
65
+ return ok(`abc263def ${alive ? 4242 : 0}\n`);
66
+ }
67
+ return ok('');
68
+ }
69
+ case 'pause':
70
+ if (docker.state !== 'running') {
71
+ return fail(`container ${name} is not running`);
72
+ }
73
+ docker.state = 'paused';
74
+ return ok(`${name}\n`);
75
+ case 'unpause':
76
+ if (docker.state !== 'paused') {
77
+ return fail(`container ${name} is not paused`);
78
+ }
79
+ docker.state = 'running';
80
+ return ok(`${name}\n`);
81
+ default:
82
+ return ok('');
83
+ }
84
+ };
85
+ return docker;
86
+ }
87
+
88
+ /**
89
+ * Run one case and return its normalized observable result.
90
+ * @param {{ExecutionRecord: Function, pause: object}} modules - Code under test
91
+ * @param {object} store - An empty ExecutionStore
92
+ * @param {object} section - `pause-control` from the contracts file
93
+ * @param {object} fixture - One of `section.cases`
94
+ * @returns {object} The value `fixture.expected` must equal
95
+ */
96
+ function runPauseCase({ ExecutionRecord, pause }, store, section, fixture) {
97
+ const base = section.record;
98
+ store.save(
99
+ ExecutionRecord.fromObject({
100
+ ...base,
101
+ status: fixture.status || base.status,
102
+ exitCode:
103
+ fixture.exitCode === undefined ? base.exitCode : fixture.exitCode,
104
+ options: { ...base.options, ...(fixture.options || {}) },
105
+ })
106
+ );
107
+ const docker = goldenDocker(fixture.docker, base.options.sessionName);
108
+ const deps = {
109
+ runner: docker.runner,
110
+ outputFormat: fixture.outputFormat,
111
+ reason: fixture.reason,
112
+ by: fixture.by,
113
+ };
114
+ const result =
115
+ fixture.action === 'pause'
116
+ ? pause.pauseExecution(store, fixture.identifier, deps)
117
+ : pause.unpauseExecution(store, fixture.identifier, deps);
118
+ const saved = store.get(base.uuid).toObject();
119
+ let output = result.output === undefined ? null : result.output;
120
+ if (output !== null && fixture.outputFormat === 'json') {
121
+ output = JSON.parse(output);
122
+ }
123
+ return normalize({
124
+ success: result.success,
125
+ output,
126
+ error: result.error === undefined ? null : result.error,
127
+ dockerCalls: docker.calls.map((call) => call.args),
128
+ unboundedDockerCalls: docker.calls
129
+ .filter((call) => !call.bounded)
130
+ .map((call) => call.args),
131
+ dockerState: docker.state,
132
+ record: {
133
+ status: saved.status,
134
+ exitCode: saved.exitCode === undefined ? null : saved.exitCode,
135
+ containerState:
136
+ saved.containerState === undefined ? null : saved.containerState,
137
+ options: saved.options,
138
+ },
139
+ });
140
+ }
141
+
142
+ module.exports = { normalize, goldenDocker, runPauseCase };
@@ -82,6 +82,9 @@ const CONTAINERS = [
82
82
  '.State.OOMKilled': String(oom),
83
83
  '.State.StartedAt': startedAt || '0001-01-01T00:00:00Z',
84
84
  '.State.FinishedAt': finishedAt || '0001-01-01T00:00:00Z',
85
+ // Paused state for --status/--list (issue #263).
86
+ '.State.Status': running ? 'running' : 'exited',
87
+ '.State.Paused': 'false',
85
88
  }));
86
89
 
87
90
  /**
@@ -290,7 +293,7 @@ posix('issue #243: one docker inspect per query, same output', () => {
290
293
  });
291
294
 
292
295
  describe('issue #243: batched inspect answers', () => {
293
- const row = (name, id) => `/${name} ${id} 7 true 0 false s f`;
296
+ const row = (name, id) => `/${name} ${id} 7 true 0 false s f running false`;
294
297
 
295
298
  it('matches lines by name or ID prefix, not by order', () => {
296
299
  const answers = parseBatchInspect(
@@ -11,6 +11,7 @@ const {
11
11
  EXIT,
12
12
  } = require('../src/lib/formal-ai-policy');
13
13
  const files = require('../src/lib/formal-ai-files');
14
+ const { readFrame, readFrameText } = files;
14
15
  const { runFormalAiTask } = require('../src/lib/formal-ai');
15
16
  const {
16
17
  cleanup,
@@ -144,11 +145,14 @@ describe('read (#258)', () => {
144
145
  const cwd = workspace();
145
146
  const ctx = context(cwd, 'read-only');
146
147
  const receipt = call('read', { path: './a.js' }, ctx);
148
+ // Formal AI binds a read as complete only in its numbered frame (#261).
147
149
  expect(receipt).toMatchObject({
148
150
  exitCode: 0,
149
- result: TEXT,
151
+ text: TEXT,
152
+ result: readFrame(TEXT),
150
153
  bytes: Buffer.byteLength(TEXT),
151
154
  });
155
+ expect(readFrameText(receipt.result)).toBe(TEXT);
152
156
  expect(ctx.observed.has('a.js')).toBe(true);
153
157
  expect(ctx.contents.get('a.js')).toBe(receipt.sha256);
154
158
  });
@@ -198,10 +202,16 @@ describe('edit (#258)', () => {
198
202
  );
199
203
  expect(receipt).toMatchObject({
200
204
  exitCode: 0,
201
- result: '',
202
205
  replacements: 1,
203
206
  verification: { verified: true },
204
207
  });
208
+ expect(JSON.parse(receipt.result)).toMatchObject({
209
+ ok: true,
210
+ path: 'a.js',
211
+ tool: 'edit',
212
+ replacements: 1,
213
+ verified: true,
214
+ });
205
215
  expect(fs.readFileSync(file, 'utf8')).toBe(
206
216
  TEXT.replace('const b = 2;', () => 'const b = $&3;')
207
217
  );
@@ -215,7 +225,7 @@ describe('edit (#258)', () => {
215
225
  it('keeps a byte order mark', () => {
216
226
  const cwd = workspace({ 'bom.txt': '\ufefffirst\nsecond\n' });
217
227
  const ctx = context(cwd);
218
- expect(call('read', { path: 'bom.txt' }, ctx).result).toBe(
228
+ expect(call('read', { path: 'bom.txt' }, ctx).text).toBe(
219
229
  '\ufefffirst\nsecond\n'
220
230
  );
221
231
  const receipt = call(
@@ -420,10 +430,22 @@ describe('file tools in the task loop (#258)', () => {
420
430
  },
421
431
  { content: 'The edit failed.' },
422
432
  ]);
433
+ // The failure stays unresolved with its original error (#261).
423
434
  expect(result.evidence.outcome).toMatchObject({
424
- status: 'completed',
435
+ status: 'incomplete',
425
436
  exitCode: 1,
426
- remainingObligations: ['the last tool call failed'],
437
+ remainingObligations: [
438
+ '`edit a.js` failed: a.js: old_string was not found; nothing changed',
439
+ ],
440
+ failures: [
441
+ {
442
+ command: 'edit a.js',
443
+ tool: 'edit',
444
+ target: 'a.js',
445
+ exitCode: 1,
446
+ error: 'a.js: old_string was not found; nothing changed',
447
+ },
448
+ ],
427
449
  });
428
450
  expect(result.after).toEqual(result.before);
429
451
  });
@@ -30,6 +30,7 @@ const {
30
30
  recognizeCapabilityGap,
31
31
  } = require('../src/lib/formal-ai-client');
32
32
  const { runFormalAiTask } = require('../src/lib/formal-ai');
33
+ const { readFrame } = require('../src/lib/formal-ai-files');
33
34
  const { createFakeServer, edits } = require('./fixtures/formal-ai/replay.cjs');
34
35
  const {
35
36
  CLI,
@@ -273,11 +274,22 @@ describe('structured file tools against captured receipts (#258)', () => {
273
274
  'reads, edits and reads back, preserving all other bytes',
274
275
  async () => {
275
276
  const result = await run(LITERAL_EDIT, { effects: 'workspace-write' });
277
+ // start verified the edit byte for byte, but Formal AI's final answer
278
+ // is a gap for the clause "Preserve all other content.": that is never
279
+ // reported as done (#261).
276
280
  expect(result.outcome).toMatchObject({
277
- status: 'completed',
278
- exitCode: 0,
279
- message: 'finished after 3 tool call(s); changed snippet.mjs',
280
- remainingObligations: [],
281
+ status: 'incomplete',
282
+ exitCode: EXIT.UNSUPPORTED,
283
+ message:
284
+ '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',
285
+ remainingObligations: [
286
+ 'Formal AI did not confirm the requested change',
287
+ ],
288
+ gapRecord: {
289
+ schema: 'obligation_gap',
290
+ reason: 'no_artifact_in_clause',
291
+ clause: 'Preserve all other content.',
292
+ },
281
293
  });
282
294
  expect(result.text).toBe(EDITED);
283
295
  const [read, edit, check] = result.tools;
@@ -304,9 +316,9 @@ describe('structured file tools against captured receipts (#258)', () => {
304
316
  command: 'sha256sum -- snippet.mjs',
305
317
  exitCode: 0,
306
318
  });
307
- // Formal AI received the exact file text and the readback hash.
319
+ // Formal AI received the exact file text (framed) and the readback hash.
308
320
  const toolMessages = result.sent[3].filter((m) => m.role === 'tool');
309
- expect(toolMessages[0].content).toBe(ORIGINAL);
321
+ expect(toolMessages[0].content).toBe(readFrame(ORIGINAL));
310
322
  expect(toolMessages[2].content).toContain(edit.sha256);
311
323
  expect(result.evidence.effects).toMatchObject({
312
324
  changed: ['snippet.mjs'],
@@ -398,7 +410,7 @@ describe('edit requests through the CLI (#258)', () => {
398
410
  const result = cli(LITERAL_EDIT, {
399
411
  START_FORMAL_AI_EFFECTS: 'workspace-write',
400
412
  });
401
- expect(result.status).toBe(0);
413
+ expect(result.status).toBe(EXIT.UNSUPPORTED);
402
414
  expect(result.text).toBe(EDITED);
403
415
  expect(result.stderr).toContain('[formal-ai] edit snippet.mjs');
404
416
  expect(result.stderr).toContain(
@@ -411,8 +423,9 @@ describe('edit requests through the CLI (#258)', () => {
411
423
  'write',
412
424
  ]);
413
425
  expect(result.evidence.outcome).toMatchObject({
414
- status: 'completed',
415
- exitCode: 0,
426
+ status: 'incomplete',
427
+ exitCode: EXIT.UNSUPPORTED,
428
+ gapRecord: { schema: 'obligation_gap' },
416
429
  });
417
430
  });
418
431