@playcraft/cli 0.1.56-beta → 0.1.58-beta

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 (101) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/tools-3d.js +15 -0
  3. package/dist/commands/tools-generation.js +3 -2
  4. package/dist/commands/workspace.js +43 -0
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace/messages.js +10 -0
  11. package/dist/workspace-runtime/batch/deliver.js +140 -80
  12. package/dist/workspace-runtime/batch/freeze.js +18 -6
  13. package/dist/workspace-runtime/batch/generate.js +68 -64
  14. package/dist/workspace-runtime/batch/prepare.js +109 -137
  15. package/dist/workspace-runtime/batch/records.js +4 -0
  16. package/dist/workspace-runtime/batch/routes.js +10 -2
  17. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  18. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  19. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  20. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  21. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  22. package/dist/workspace-runtime/codex/app-server.js +1 -0
  23. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  24. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  25. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  26. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  27. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  28. package/dist/workspace-runtime/persistence/journal.js +34 -0
  29. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  30. package/dist/workspace-runtime/previews/output.js +2 -0
  31. package/dist/workspace-runtime/previews/publish.js +20 -5
  32. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  33. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  34. package/dist/workspace-runtime/serve.js +19 -32
  35. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  36. package/dist/workspace-runtime/server/directory-access.js +414 -0
  37. package/dist/workspace-runtime/server/dispatch.js +61 -23
  38. package/dist/workspace-runtime/server/endpoints.js +1 -0
  39. package/dist/workspace-runtime/server/http.js +52 -10
  40. package/dist/workspace-runtime/server/operation-record.js +2 -0
  41. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  42. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  43. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  44. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  45. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  46. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  47. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  48. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  49. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  50. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  51. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  52. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  53. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  54. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  55. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  56. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  57. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  58. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  59. package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
  60. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  61. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  62. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  63. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  64. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  65. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  66. package/dist/workspace-runtime/workspaces/files.js +4 -0
  67. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  68. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  69. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  70. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  71. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  72. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  73. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  74. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  75. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  76. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  77. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  78. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  79. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  80. package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
  81. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  82. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  83. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  84. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  85. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  86. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  87. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  88. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  89. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  90. package/package.json +5 -4
  91. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  92. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  93. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
  94. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  95. package/templates/preview-runtime/v1/README.md +4 -0
  96. package/templates/preview-runtime/v1/manifest.json +6 -6
  97. package/templates/preview-runtime/v1/runtime.mjs +368 -22
  98. package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
  99. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  100. package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
  101. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -1,13 +1,62 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { constants } from 'node:fs';
2
- import { lstat, open } from 'node:fs/promises';
3
+ import { lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
7
+ import { runDeveloperCommand } from '../workspaces/developer-command.js';
5
8
  import { workspaceRoot } from '../workspaces/registry.js';
6
9
  import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
10
+ import { stopMemberWriters } from './writer-proof.js';
7
11
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
8
12
  /** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
9
13
  function buildCommand() {
10
- return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND ?? 'CI=1 playcraft build --mode base --output dist .');
14
+ return (process.env.PLAYCRAFT_BATCH_BUILD_COMMAND
15
+ ?? 'umask 002 && CI=1 playcraft build --mode base --output dist .');
16
+ }
17
+ /**
18
+ * The batch build runs as the runtime identity while the agent wrote the tree as the development
19
+ * identity. Directories the agent created private (browser profiles, tool caches) are unreadable to
20
+ * the build user, and the build's pre-build scan fails hard on the first one — which is how a
21
+ * delivered-looking member used to end as `batch_build_failed`. Only the owning identity may chmod,
22
+ * so the repair has to run through the launcher rather than the runtime user.
23
+ */
24
+ const PERMISSION_REPAIR_COMMAND = [
25
+ 'find . \\( -path ./node_modules -o -path ./.git \\) -prune -o',
26
+ '\\( -type d \\( -not -perm -040 -o -not -perm -010 \\) -o -type f -not -perm -040 \\)',
27
+ '-exec chmod g+rX {} +',
28
+ ].join(' ');
29
+ const BUILD_LOG_LIMIT = 64 * 1024;
30
+ /** Best effort: a failed repair is recorded in the log and never hides the build's own error. */
31
+ async function repairGroupAccess(state, root, runId) {
32
+ let output = '';
33
+ try {
34
+ const outcome = await runDeveloperCommand(state, {
35
+ processId: `batch-deliver-repair-${runId}-${randomUUID()}`,
36
+ cwd: root,
37
+ command: PERMISSION_REPAIR_COMMAND,
38
+ timeoutMs: 30_000,
39
+ onOutput: (chunk) => {
40
+ output = (output + String(chunk)).slice(-BUILD_LOG_LIMIT);
41
+ },
42
+ });
43
+ if (outcome.code !== 0)
44
+ output += `\n[repair] exit ${outcome.code ?? 'unknown'}${outcome.timedOut ? ' (timeout)' : ''}`;
45
+ }
46
+ catch (error) {
47
+ output += `\n[repair] failed: ${error instanceof Error ? error.message : String(error)}`;
48
+ }
49
+ return output;
50
+ }
51
+ /** Keeps the build's own stderr next to the run so a failure is diagnosable without a re-run. */
52
+ async function writeBuildLog(state, runId, text) {
53
+ const directory = join(state.config.stateDir, 'batch-temp', runId);
54
+ await mkdir(directory, { recursive: true });
55
+ await writeFile(join(directory, 'build.log'), text.slice(-BUILD_LOG_LIMIT));
56
+ }
57
+ /** A build command that writes nothing must not inherit an earlier run's index.html as success. */
58
+ async function clearBuiltHtml(root) {
59
+ await rm(join(root, 'dist', 'index.html'), { force: true }).catch(() => undefined);
11
60
  }
12
61
  async function readBuiltHtml(root) {
13
62
  const htmlPath = join(root, 'dist', 'index.html');
@@ -67,86 +116,97 @@ export async function deliverBatchMember(state, body) {
67
116
  existing.state === 'completed') {
68
117
  throw new Error('batch_command_conflict');
69
118
  }
70
- const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
71
- let record = await writeMember(state, {
72
- runId: command.runId,
73
- ownerFence: command.ownerFence,
74
- occupancyId: command.occupancyId,
75
- workspaceId: command.workspaceId,
76
- directoryName: command.directoryName,
77
- phase: 'deliver',
78
- state: 'running',
79
- writersStopped: false,
80
- holding: true,
81
- stopRequested: false,
82
- processes: existing?.processes ?? [],
83
- sessionId: existing?.sessionId,
84
- operationId: existing?.operationId,
85
- commandHash: { ...existing?.commandHash, deliver: hash },
86
- snapshotDigest: existing?.snapshotDigest,
87
- branchName: existing?.branchName,
88
- updatedAt: new Date().toISOString(),
89
- sourceCommit: existing?.sourceCommit ??
90
- state.journal
91
- .getWorkspace(command.workspaceId)
92
- ?.directories?.find((directory) => directory.directoryName === command.directoryName)
93
- ?.sourceCommit,
94
- beforeTree: existing?.beforeTree,
95
- });
96
- try {
97
- if (await readStopMarker(state, command.runId))
98
- return memberStatus(await markStopped(state, command, record));
99
- const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
100
- record = await writeMember(state, { ...record, processes: [...record.processes, process] });
101
- });
102
- record = await writeMember(state, {
103
- ...record,
104
- processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
119
+ await claimBatchDirectory(state, command);
120
+ return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
121
+ const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
122
+ let record = await writeMember(state, {
123
+ runId: command.runId,
124
+ ownerFence: command.ownerFence,
125
+ occupancyId: command.occupancyId,
126
+ workspaceId: command.workspaceId,
127
+ directoryName: command.directoryName,
128
+ phase: 'deliver',
129
+ state: 'running',
130
+ writersStopped: false,
131
+ holding: true,
132
+ stopRequested: false,
133
+ processes: existing?.processes ?? [],
134
+ sessionId: existing?.sessionId,
135
+ operationId: existing?.operationId,
136
+ commandHash: { ...existing?.commandHash, deliver: hash },
137
+ snapshotDigest: existing?.snapshotDigest,
138
+ branchName: existing?.branchName,
139
+ updatedAt: new Date().toISOString(),
140
+ sourceCommit: existing?.sourceCommit ??
141
+ state.journal
142
+ .getWorkspace(command.workspaceId)
143
+ ?.directories?.find((directory) => directory.directoryName === command.directoryName)
144
+ ?.sourceCommit,
145
+ beforeTree: existing?.beforeTree,
105
146
  });
106
- if (await readStopMarker(state, command.runId))
107
- return memberStatus(await markStopped(state, command, record));
108
- if (built.code !== 0)
109
- throw new Error('batch_build_failed');
110
- await readBuiltHtml(root);
111
- const writersStopped = await prove(record);
112
- if (!writersStopped)
113
- return memberStatus(await writeMember(state, {
147
+ try {
148
+ if (await readStopMarker(state, command.runId))
149
+ return memberStatus(await markStopped(state, command, record));
150
+ const repairOutput = await repairGroupAccess(state, root, command.runId);
151
+ await clearBuiltHtml(root);
152
+ const built = await runProcessGroup(root, buildCommand(), 'build', async (process) => {
153
+ record = await writeMember(state, { ...record, processes: [...record.processes, process] });
154
+ });
155
+ record = await writeMember(state, {
114
156
  ...record,
115
- state: 'stopping',
116
- writersStopped: false,
117
- holding: true,
118
- }));
119
- if (await readStopMarker(state, command.runId))
120
- return memberStatus(await markStopped(state, command, record));
121
- record = await writeMember(state, {
122
- ...record,
123
- state: 'completed',
124
- writersStopped: true,
125
- holding: false,
126
- error: undefined,
127
- evidence: { verified: true },
128
- });
129
- return memberStatus(record);
130
- }
131
- catch (error) {
132
- const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
133
- ? error.message
134
- : 'batch_deliver_failed';
135
- const writersStopped = await prove(record);
136
- record = await writeMember(state, {
137
- ...record,
138
- state: writersStopped ? 'failed' : 'stopping',
139
- writersStopped,
140
- holding: !writersStopped,
141
- error: code,
142
- });
143
- return memberStatus(record);
144
- }
157
+ processes: record.processes.map((process) => process.pid === built.process.pid ? built.process : process),
158
+ });
159
+ if (await readStopMarker(state, command.runId))
160
+ return memberStatus(await markStopped(state, command, record));
161
+ const buildLog = [repairOutput, built.output].filter(Boolean).join('\n');
162
+ if (built.code !== 0) {
163
+ await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
164
+ const tail = built.output.trim().split('\n').slice(-3).join(' | ').slice(0, 400);
165
+ if (tail)
166
+ console.warn(`[workspace-runtime] batch build failed run=${command.runId} code=${built.code}: ${tail}`);
167
+ throw new Error('batch_build_failed');
168
+ }
169
+ await writeBuildLog(state, command.runId, buildLog).catch(() => undefined);
170
+ await readBuiltHtml(root);
171
+ const writersStopped = await prove(record);
172
+ if (!writersStopped)
173
+ return memberStatus(await writeMember(state, {
174
+ ...record,
175
+ state: 'stopping',
176
+ writersStopped: false,
177
+ holding: true,
178
+ }));
179
+ if (await readStopMarker(state, command.runId))
180
+ return memberStatus(await markStopped(state, command, record));
181
+ record = await writeMember(state, {
182
+ ...record,
183
+ state: 'completed',
184
+ writersStopped: true,
185
+ holding: false,
186
+ error: undefined,
187
+ evidence: { verified: true },
188
+ });
189
+ return memberStatus(record);
190
+ }
191
+ catch (error) {
192
+ const code = error instanceof Error && /^[a-z0-9_]+$/.test(error.message)
193
+ ? error.message
194
+ : 'batch_deliver_failed';
195
+ const writersStopped = await prove(record);
196
+ record = await writeMember(state, {
197
+ ...record,
198
+ state: writersStopped ? 'failed' : 'stopping',
199
+ writersStopped,
200
+ holding: !writersStopped,
201
+ error: code,
202
+ });
203
+ return memberStatus(record);
204
+ }
205
+ });
145
206
  });
146
207
  }
147
208
  async function markStopped(state, command, existing) {
148
- if (existing)
149
- await prove(existing);
209
+ const writersStopped = await stopMemberWriters(state, existing);
150
210
  return writeMember(state, {
151
211
  runId: command.runId,
152
212
  ownerFence: command.ownerFence,
@@ -154,9 +214,9 @@ async function markStopped(state, command, existing) {
154
214
  workspaceId: command.workspaceId,
155
215
  directoryName: command.directoryName,
156
216
  phase: 'deliver',
157
- state: 'stopped',
158
- writersStopped: true,
159
- holding: false,
217
+ state: writersStopped ? 'stopped' : 'stopping',
218
+ writersStopped,
219
+ holding: !writersStopped,
160
220
  stopRequested: true,
161
221
  processes: existing?.processes ?? [],
162
222
  sessionId: existing?.sessionId,
@@ -9,6 +9,7 @@ import { filesystemObjectStore, readSnapshot, serializeFreeze, sourceCacheDir, w
9
9
  import { assertSourceIdentity, readSourceCandidate, writeSourceCandidate } from './source-candidate.js';
10
10
  import { BatchSourceFreezeError, sourceFreezeError } from './source-failure.js';
11
11
  import { publishSourceCandidate } from './source-publish.js';
12
+ import { clearDirectoryFreeze, withDirectoryFreeze } from '../server/directory-access.js';
12
13
  async function usagePercent(path) {
13
14
  const disk = await statfs(path);
14
15
  if (disk.blocks <= 0)
@@ -21,11 +22,8 @@ async function stopManagedWriters(state, workspaceId, directoryName) {
21
22
  for (const write of state.journal.listManagedWrites()) {
22
23
  if (write.stoppedAt || write.workspaceId !== scope)
23
24
  continue;
24
- if (write.pid !== undefined) {
25
- const stopped = await stopBackgroundWriter(write.pid);
26
- if (!stopped)
27
- continue;
28
- }
25
+ if (write.pid === undefined || !await stopBackgroundWriter(write.pid))
26
+ continue;
29
27
  await state.journal.putManagedWrite({ ...write, stoppedAt: new Date().toISOString() });
30
28
  }
31
29
  }
@@ -34,7 +32,14 @@ export async function freezeBatchSource(state, body, options) {
34
32
  if (!parsed.success)
35
33
  throw new Error('invalid_batch_request');
36
34
  const request = parsed.data;
37
- return serializeFreeze(async () => {
35
+ const frozen = await readSnapshot(state, request.batchId);
36
+ if (frozen) {
37
+ assertSourceIdentity(state, request, frozen, await readSourceCandidate(state, request.batchId));
38
+ await clearDirectoryFreeze(state, request.batchId);
39
+ return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: frozen });
40
+ }
41
+ return withDirectoryFreeze(state, { workspaceId: request.workspaceId,
42
+ directoryName: request.sourceDirectoryName, batchId: request.batchId }, () => serializeFreeze(async () => {
38
43
  let candidate;
39
44
  let source;
40
45
  try {
@@ -60,6 +65,7 @@ export async function freezeBatchSource(state, body, options) {
60
65
  const cache = sourceCacheDir(state, request.batchId);
61
66
  await rm(cache, { recursive: true, force: true });
62
67
  const captured = await captureIndependentSource(source, cache);
68
+ // private
63
69
  await writeFile(`${cache}.digest`, captured.digest, { mode: 0o600 });
64
70
  const binding = state.journal.getWorkspace(request.workspaceId);
65
71
  const snapshot = {
@@ -97,5 +103,11 @@ export async function freezeBatchSource(state, body, options) {
97
103
  throw await sourceFreezeError(state, request.batchId, { code: 'batch_source_verification_failed', stage: 'verify', retryable: true, outcome: 'unknown' }, error);
98
104
  }
99
105
  return batchSourceFreezeResultSchema.parse({ status: 'frozen', snapshot: candidate.snapshot });
106
+ }), () => stopManagedWriters(state, request.workspaceId, request.sourceDirectoryName), {
107
+ signal: options?.signal, deadline: 'deadline' in request && typeof request.deadline === 'string' ? request.deadline : undefined,
108
+ }).catch(async (error) => {
109
+ if (error instanceof Error && error.message === 'directory_access_cancelled')
110
+ throw await sourceFreezeError(state, request.batchId, { code: 'source_freeze_timeout', stage: 'capture', retryable: false, outcome: 'not_published' });
111
+ throw error;
100
112
  });
101
113
  }
@@ -4,12 +4,14 @@ import { join } from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { hashOperationPayload, operationScopeKey, sandboxBatchMemberGenerateSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { attachNativeCollector, executeOperation } from '../server/dispatch.js';
7
+ import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
7
8
  import { workspaceRoot } from '../workspaces/registry.js';
8
9
  import { snapshotWorkspaceTree } from '../workspaces/git.js';
9
10
  import { requireBatchContentChanges } from './content-changes.js';
10
11
  import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
11
12
  import { groupExited, stopProcessGroup } from './processes.js';
12
13
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
14
+ import { stopMemberWriters } from './writer-proof.js';
13
15
  function hashCommand(value) {
14
16
  return hashOperationPayload(value);
15
17
  }
@@ -74,63 +76,69 @@ export async function generateBatchMember(state, body) {
74
76
  }
75
77
  if (existing && existing.ownerFence === command.ownerFence && existing.commandHash?.generate && existing.commandHash.generate !== hash)
76
78
  throw new Error('batch_command_conflict');
77
- const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
78
- const sessionId = command.sessionId ?? existing?.sessionId ?? `member-${command.runId}`;
79
- const tempDir = join(state.config.stateDir, 'batch-temp', command.runId);
80
- await mkdir(tempDir, { recursive: true, mode: 0o700 });
81
- const policy = memberExecutionPolicy(root, tempDir);
82
- await bindMemberPolicy(state, sessionId, policy);
83
- const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
84
- const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
85
- const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
86
- const prior = state.journal.getOperation(turnKey.scopeKey);
87
- if (prior && prior.payloadHash !== turnKey.record.payloadHash)
88
- throw new Error('batch_command_conflict');
89
- const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
90
- const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
91
- let record = await writeMember(state, {
92
- runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
93
- directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
94
- writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
95
- operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
96
- snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
97
- sourceCommit, beforeTree,
98
- updatedAt: new Date().toISOString(),
99
- });
100
- if (prior)
101
- return memberStatus(await finishGenerate(state, record, prior, root));
102
- if (!state.native?.ready()) {
103
- record = await writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: 'capability_unavailable' });
104
- return memberStatus(record);
105
- }
106
- const terminals = new Map();
107
- const unsubscribe = state.native.onCollected?.((event) => {
108
- const threadId = threadIdOf(event);
109
- const terminal = turnTerminal(event);
110
- if (!threadId || !terminal)
111
- return;
112
- if (terminals.get(threadId) === 'needs_user')
113
- return;
114
- terminals.set(threadId, terminal);
115
- });
116
- try {
117
- if (!state.journal.getSession(sessionId)) {
118
- const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
119
- if (!state.journal.getOperation(created.scopeKey))
120
- await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
79
+ if (existing && !existing.holding && existing.writersStopped)
80
+ return memberStatus(existing);
81
+ await claimBatchDirectory(state, command);
82
+ return withDirectoryOwner(state, command, `run:${command.runId}`, async () => {
83
+ const root = await workspaceRoot(state, command.workspaceId, command.directoryName);
84
+ const sessionId = command.sessionId ?? existing?.sessionId ?? `member-${command.runId}`;
85
+ const tempDir = join(state.config.stateDir, 'batch-temp', command.runId);
86
+ // private
87
+ await mkdir(tempDir, { recursive: true, mode: 0o700 });
88
+ const policy = memberExecutionPolicy(root, tempDir);
89
+ await bindMemberPolicy(state, sessionId, policy);
90
+ const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
91
+ const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
92
+ const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
93
+ const prior = state.journal.getOperation(turnKey.scopeKey);
94
+ if (prior && prior.payloadHash !== turnKey.record.payloadHash)
95
+ throw new Error('batch_command_conflict');
96
+ const sourceCommit = existing?.sourceCommit ?? state.journal.getWorkspace(command.workspaceId)?.directories?.find(directory => directory.directoryName === command.directoryName)?.sourceCommit;
97
+ const beforeTree = existing?.beforeTree ?? (prior ? sourceCommit : await snapshotWorkspaceTree(root));
98
+ let record = await writeMember(state, {
99
+ runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
100
+ directoryName: command.directoryName, phase: 'generate', state: prior ? (prior.status === 'uncertain' ? 'unknown' : prior.status === 'completed' ? 'completed' : 'running') : 'running',
101
+ writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
102
+ operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
103
+ snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
104
+ sourceCommit, beforeTree,
105
+ updatedAt: new Date().toISOString(),
106
+ });
107
+ if (prior)
108
+ return memberStatus(await finishGenerate(state, record, prior, root));
109
+ if (!state.native?.ready()) {
110
+ record = await writeMember(state, { ...record, state: 'failed', writersStopped: true, holding: false, error: 'capability_unavailable' });
111
+ return memberStatus(record);
121
112
  }
122
- if (await readStopMarker(state, command.runId))
123
- return memberStatus(await stoppedRecord(state, command, record));
124
- const started = await executeOperation(state, turnKey.scopeKey, turnKey.record, turnPayload);
125
- record = await writeMember(state, { ...record, operationId: started.operationId, sessionId });
126
- return memberStatus(await finishGenerate(state, record, started, root, () => {
127
- const threadId = state.journal.getSession(sessionId)?.threadId;
128
- return threadId ? terminals.get(threadId) : undefined;
129
- }));
130
- }
131
- finally {
132
- unsubscribe?.();
133
- }
113
+ const terminals = new Map();
114
+ const unsubscribe = state.native.onCollected?.((event) => {
115
+ const threadId = threadIdOf(event);
116
+ const terminal = turnTerminal(event);
117
+ if (!threadId || !terminal)
118
+ return;
119
+ if (terminals.get(threadId) === 'needs_user')
120
+ return;
121
+ terminals.set(threadId, terminal);
122
+ });
123
+ try {
124
+ if (!state.journal.getSession(sessionId)) {
125
+ const created = operation(state, { kind: 'session.create', clientRequestId: `batch-create:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) } });
126
+ if (!state.journal.getOperation(created.scopeKey))
127
+ await executeOperation(state, created.scopeKey, created.record, { permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) });
128
+ }
129
+ if (await readStopMarker(state, command.runId))
130
+ return memberStatus(await stoppedRecord(state, command, record));
131
+ const started = await executeOperation(state, turnKey.scopeKey, turnKey.record, turnPayload);
132
+ record = await writeMember(state, { ...record, operationId: started.operationId, sessionId });
133
+ return memberStatus(await finishGenerate(state, record, started, root, () => {
134
+ const threadId = state.journal.getSession(sessionId)?.threadId;
135
+ return threadId ? terminals.get(threadId) : undefined;
136
+ }));
137
+ }
138
+ finally {
139
+ unsubscribe?.();
140
+ }
141
+ });
134
142
  });
135
143
  }
136
144
  async function finishGenerate(state, record, started, root, early) {
@@ -142,7 +150,7 @@ async function finishGenerate(state, record, started, root, early) {
142
150
  return writeMember(state, { ...record, state: 'unknown', writersStopped: false, holding: true, operationId: started.operationId, error: 'native_receipt_lost' });
143
151
  }
144
152
  if (!terminal && started.status === 'failed') {
145
- const writersStopped = await proveStopped(record);
153
+ const writersStopped = await stopMemberWriters(state, record);
146
154
  return writeMember(state, { ...record, state: writersStopped ? 'failed' : 'stopping', writersStopped, holding: !writersStopped, operationId: started.operationId, error: started.error ?? 'batch_turn_failed' });
147
155
  }
148
156
  const deadline = Date.now() + 5_000;
@@ -161,10 +169,7 @@ async function finishGenerate(state, record, started, root, early) {
161
169
  await delay(15);
162
170
  }
163
171
  if (terminal === 'needs_user') {
164
- const session = state.journal.getSession(sessionId);
165
- if (session?.threadId)
166
- await state.native?.interrupt?.({ sessionId, threadId: session.threadId }).catch(() => undefined);
167
- const writersStopped = await proveStopped(record);
172
+ const writersStopped = await stopMemberWriters(state, record);
168
173
  if (await readStopMarker(state, record.runId))
169
174
  return stoppedRecord(state, record, record);
170
175
  return writeMember(state, {
@@ -236,11 +241,10 @@ async function completeGeneration(state, record, root) {
236
241
  return writeMember(state, { ...record, state: 'completed', writersStopped: true, holding: true, headCommit: head, error: undefined });
237
242
  }
238
243
  async function stoppedRecord(state, command, existing) {
239
- if (existing)
240
- await proveStopped(existing);
244
+ const writersStopped = await stopMemberWriters(state, existing);
241
245
  return writeMember(state, {
242
246
  runId: command.runId, ownerFence: command.ownerFence, occupancyId: command.occupancyId, workspaceId: command.workspaceId,
243
- directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: 'stopped', writersStopped: true, holding: false,
247
+ directoryName: command.directoryName, phase: existing?.phase ?? 'generate', state: writersStopped ? 'stopped' : 'stopping', writersStopped, holding: !writersStopped,
244
248
  stopRequested: true, processes: existing?.processes ?? [], sessionId: existing?.sessionId, operationId: existing?.operationId,
245
249
  headCommit: existing?.headCommit, evidence: existing?.evidence, commandHash: existing?.commandHash, snapshotDigest: existing?.snapshotDigest,
246
250
  branchName: existing?.branchName, updatedAt: new Date().toISOString(),