@dharma-ai-labs/agent-fabric-task-runner 0.1.5 → 0.1.7

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.
package/dist/index.js CHANGED
@@ -1,10 +1,18 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
4
4
  import { isAbsolute, relative, resolve } from 'node:path';
5
- import { validateTaskEnvelopeContract, verifyCanonicalObject } from '@dharma-ai-labs/agent-fabric-contracts';
5
+ import { buildActionDecisionAcknowledgement, validateActionDecisionAcknowledgementContract, validateTaskEnvelopeContract, verifyActionDecisionReceipt, verifyCanonicalObject, } from '@dharma-ai-labs/agent-fabric-contracts';
6
6
  import { assertPathWithinWorkspace, resolveRegisteredCommand } from '@dharma-ai-labs/agent-fabric-policy';
7
7
  import { executeProviderTask, } from '@dharma-ai-labs/agent-fabric-provider-adapters';
8
+ export class ActionDecisionDeniedError extends Error {
9
+ receipt;
10
+ constructor(message, receipt) {
11
+ super(message);
12
+ this.receipt = receipt;
13
+ this.name = 'ActionDecisionDeniedError';
14
+ }
15
+ }
8
16
  function normalizePolicyPath(value) {
9
17
  return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
10
18
  }
@@ -132,6 +140,48 @@ export function providerInstructionsForTask(task) {
132
140
  throw new Error('A2A provider instructions exceed the execution limit.');
133
141
  return instructions;
134
142
  }
143
+ function receiptForDurableStorage(receipt) {
144
+ return {
145
+ ...receipt,
146
+ commandResults: receipt.commandResults.map((result) => ({ ...result, stdout: '', stderr: '' })),
147
+ };
148
+ }
149
+ function assertValidTaskReceipt(value, expectedTaskId) {
150
+ if (!value || typeof value !== 'object' || Array.isArray(value))
151
+ throw new Error('Task receipt is invalid.');
152
+ const receipt = value;
153
+ if (typeof receipt.taskId !== 'string' || !/^[0-9a-f-]{36}$/i.test(receipt.taskId)
154
+ || (expectedTaskId && receipt.taskId !== expectedTaskId)
155
+ || !['completed', 'failed', 'cancelled'].includes(String(receipt.status))
156
+ || typeof receipt.worktree !== 'string' || !isAbsolute(receipt.worktree)
157
+ || receipt.branch !== `dharma/task/${receipt.taskId}`
158
+ || !Array.isArray(receipt.commandResults) || receipt.commandResults.length > 100
159
+ || !Number.isFinite(Date.parse(String(receipt.startedAt || '')))
160
+ || !Number.isFinite(Date.parse(String(receipt.completedAt || '')))
161
+ || Date.parse(String(receipt.completedAt)) < Date.parse(String(receipt.startedAt))) {
162
+ throw new Error('Task receipt is invalid.');
163
+ }
164
+ for (const result of receipt.commandResults) {
165
+ if (!result || typeof result !== 'object' || Array.isArray(result))
166
+ throw new Error('Task receipt command result is invalid.');
167
+ const command = result;
168
+ if (typeof command.commandId !== 'string' || command.commandId.length < 1 || command.commandId.length > 200
169
+ || !(command.exitCode === null || Number.isInteger(command.exitCode))
170
+ || !(command.signal === null || typeof command.signal === 'string')
171
+ || typeof command.timedOut !== 'boolean'
172
+ || !/^sha256:[a-f0-9]{64}$/.test(String(command.stdoutSha256 || ''))
173
+ || !/^sha256:[a-f0-9]{64}$/.test(String(command.stderrSha256 || ''))
174
+ || typeof command.stdout !== 'string' || typeof command.stderr !== 'string') {
175
+ throw new Error('Task receipt command result is invalid.');
176
+ }
177
+ }
178
+ if (receipt.actionAcknowledgement !== undefined) {
179
+ const acknowledgement = validateActionDecisionAcknowledgementContract(receipt.actionAcknowledgement);
180
+ if (!acknowledgement.ok || receipt.actionAcknowledgement.taskId !== receipt.taskId) {
181
+ throw new Error('Task receipt action acknowledgement is invalid.');
182
+ }
183
+ }
184
+ }
135
185
  export class FileTaskReceiptStore {
136
186
  directory;
137
187
  constructor(directory) {
@@ -139,7 +189,9 @@ export class FileTaskReceiptStore {
139
189
  }
140
190
  async get(taskId) {
141
191
  try {
142
- return JSON.parse(await readFile(resolve(this.directory, `${taskId}.json`), 'utf8'));
192
+ const receipt = JSON.parse(await readFile(resolve(this.directory, `${taskId}.json`), 'utf8'));
193
+ assertValidTaskReceipt(receipt, taskId);
194
+ return receipt;
143
195
  }
144
196
  catch (error) {
145
197
  if (error.code === 'ENOENT')
@@ -148,15 +200,302 @@ export class FileTaskReceiptStore {
148
200
  }
149
201
  }
150
202
  async put(receipt) {
151
- await mkdir(this.directory, { recursive: true, mode: 0o700 });
203
+ assertValidTaskReceipt(receipt, receipt.taskId);
152
204
  const target = resolve(this.directory, `${receipt.taskId}.json`);
153
- const temp = `${target}.${process.pid}.tmp`;
154
- await writeFile(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
155
- await rename(temp, target);
205
+ await durableJsonWrite(target, receiptForDurableStorage(receipt));
206
+ }
207
+ }
208
+ async function durableJsonWrite(path, value) {
209
+ const directory = resolve(path, '..');
210
+ await mkdir(directory, { recursive: true, mode: 0o700 });
211
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
212
+ const handle = await open(temp, 'wx', 0o600);
213
+ try {
214
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
215
+ await handle.sync();
216
+ }
217
+ finally {
218
+ await handle.close();
219
+ }
220
+ await rename(temp, path);
221
+ try {
222
+ const directoryHandle = await open(directory, 'r');
223
+ try {
224
+ await directoryHandle.sync();
225
+ }
226
+ finally {
227
+ await directoryHandle.close();
228
+ }
229
+ }
230
+ catch {
231
+ // Directory fsync is unavailable on some supported hosts; the file itself
232
+ // has still been flushed before the atomic rename.
233
+ }
234
+ }
235
+ export class FileActionExecutionJournal {
236
+ directory;
237
+ pid;
238
+ now;
239
+ ownerId = randomUUID();
240
+ constructor(directory, pid = process.pid, now = () => new Date()) {
241
+ this.directory = directory;
242
+ this.pid = pid;
243
+ this.now = now;
244
+ }
245
+ recordPath(taskId) { return resolve(this.directory, `${taskId}.json`); }
246
+ claimPath(taskId) { return resolve(this.directory, `${taskId}.claim.json`); }
247
+ async get(taskId) {
248
+ try {
249
+ const record = JSON.parse(await readFile(this.recordPath(taskId), 'utf8'));
250
+ if (record.schema !== 'dharma.action-execution-journal/v1' || record.taskId !== taskId
251
+ || !/^[0-9a-f-]{36}$/i.test(record.taskId)
252
+ || !/^[0-9a-f-]{36}$/i.test(record.decisionId) || !/^[0-9a-f-]{36}$/i.test(record.endpointId)
253
+ || !/^sha256:[a-f0-9]{64}$/.test(record.actionDigest)
254
+ || typeof record.externalIdempotencyKey !== 'string' || !record.externalIdempotencyKey
255
+ || typeof record.worktree !== 'string' || !isAbsolute(record.worktree)
256
+ || record.branch !== `dharma/task/${record.taskId}`
257
+ || !['prepared', 'replay_authorization_started', 'replay_authorized', 'executing', 'effect_observed', 'receipt_recorded'].includes(record.state)
258
+ || !Number.isFinite(Date.parse(record.preparedAt)) || !Number.isFinite(Date.parse(record.updatedAt))
259
+ || (record.providerResultDigest !== undefined && !/^sha256:[a-f0-9]{64}$/.test(record.providerResultDigest))) {
260
+ throw new Error('Action execution journal record is invalid.');
261
+ }
262
+ if (record.state === 'receipt_recorded' && !record.receipt) {
263
+ throw new Error('Action execution journal is missing its recorded receipt.');
264
+ }
265
+ if (record.receipt) {
266
+ assertValidTaskReceipt(record.receipt, taskId);
267
+ const acknowledgement = record.receipt.actionAcknowledgement;
268
+ if (acknowledgement
269
+ && (acknowledgement.endpointId !== record.endpointId || acknowledgement.actionDigest !== record.actionDigest)) {
270
+ throw new Error('Action execution journal receipt conflicts with the signed decision.');
271
+ }
272
+ }
273
+ return record;
274
+ }
275
+ catch (error) {
276
+ if (error.code === 'ENOENT')
277
+ return null;
278
+ throw error;
279
+ }
280
+ }
281
+ async getClaim(taskId) {
282
+ try {
283
+ const claim = JSON.parse(await readFile(this.claimPath(taskId), 'utf8'));
284
+ if (claim.schema !== 'dharma.action-execution-claim/v1' || claim.taskId !== taskId
285
+ || !/^[0-9a-f-]{36}$/i.test(claim.taskId) || !/^[0-9a-f-]{36}$/i.test(claim.decisionId)
286
+ || !/^sha256:[a-f0-9]{64}$/.test(claim.actionDigest)
287
+ || typeof claim.ownerId !== 'string' || !Number.isInteger(claim.pid) || claim.pid <= 0
288
+ || !Number.isFinite(Date.parse(claim.claimedAt)) || !Number.isFinite(Date.parse(claim.expiresAt))
289
+ || Date.parse(claim.expiresAt) <= Date.parse(claim.claimedAt)) {
290
+ throw new Error('Action execution claim is invalid.');
291
+ }
292
+ return claim;
293
+ }
294
+ catch (error) {
295
+ if (error.code === 'ENOENT')
296
+ return null;
297
+ throw error;
298
+ }
299
+ }
300
+ async prepare(input) {
301
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
302
+ const now = this.now().toISOString();
303
+ const record = {
304
+ schema: 'dharma.action-execution-journal/v1', ...input,
305
+ state: 'prepared', preparedAt: now, updatedAt: now,
306
+ };
307
+ try {
308
+ const handle = await open(this.recordPath(input.taskId), 'wx', 0o600);
309
+ try {
310
+ await handle.writeFile(`${JSON.stringify(record)}\n`);
311
+ await handle.sync();
312
+ }
313
+ finally {
314
+ await handle.close();
315
+ }
316
+ return { record, created: true };
317
+ }
318
+ catch (error) {
319
+ if (error.code !== 'EEXIST')
320
+ throw error;
321
+ const existing = await this.get(input.taskId);
322
+ if (!existing
323
+ || existing.decisionId !== input.decisionId
324
+ || existing.endpointId !== input.endpointId
325
+ || existing.actionDigest !== input.actionDigest
326
+ || existing.externalIdempotencyKey !== input.externalIdempotencyKey
327
+ || existing.worktree !== input.worktree
328
+ || existing.branch !== input.branch) {
329
+ throw new Error('Action execution journal conflicts with the signed task.');
330
+ }
331
+ return { record: existing, created: false };
332
+ }
333
+ }
334
+ async transition(taskId, allowed, state, patch = {}) {
335
+ const current = await this.get(taskId);
336
+ if (!current || !allowed.includes(current.state)) {
337
+ throw new Error(`Action execution journal transition to ${state} is invalid.`);
338
+ }
339
+ const next = { ...current, ...patch, state, updatedAt: this.now().toISOString() };
340
+ await durableJsonWrite(this.recordPath(taskId), next);
341
+ return next;
342
+ }
343
+ async claim(record, maximumAgeMs) {
344
+ if (record.state !== 'replay_authorized')
345
+ throw new Error('Action execution is not authorized for claiming.');
346
+ const boundedAgeMs = Math.min(Math.max(maximumAgeMs, 1), 24 * 60 * 60_000);
347
+ const claim = {
348
+ schema: 'dharma.action-execution-claim/v1', taskId: record.taskId,
349
+ decisionId: record.decisionId, actionDigest: record.actionDigest,
350
+ ownerId: this.ownerId, pid: this.pid, claimedAt: this.now().toISOString(),
351
+ expiresAt: new Date(this.now().getTime() + boundedAgeMs).toISOString(),
352
+ };
353
+ const handle = await open(this.claimPath(record.taskId), 'wx', 0o600);
354
+ try {
355
+ await handle.writeFile(`${JSON.stringify(claim)}\n`);
356
+ await handle.sync();
357
+ }
358
+ finally {
359
+ await handle.close();
360
+ }
361
+ await this.transition(record.taskId, ['replay_authorized'], 'executing');
362
+ return claim;
363
+ }
364
+ isClaimOwnerAlive(claim) {
365
+ if (Date.parse(claim.expiresAt) <= this.now().getTime())
366
+ return false;
367
+ if (claim.ownerId === this.ownerId && claim.pid === this.pid)
368
+ return true;
369
+ try {
370
+ process.kill(claim.pid, 0);
371
+ return true;
372
+ }
373
+ catch (error) {
374
+ return error.code === 'EPERM';
375
+ }
376
+ }
377
+ async recordEffectObserved(taskId, providerResult) {
378
+ await this.transition(taskId, ['executing'], 'effect_observed', {
379
+ providerResultDigest: `sha256:${createHash('sha256').update(JSON.stringify(providerResult)).digest('hex')}`,
380
+ });
381
+ }
382
+ async recordReceipt(receipt) {
383
+ assertValidTaskReceipt(receipt, receipt.taskId);
384
+ await this.transition(receipt.taskId, [
385
+ 'prepared', 'replay_authorization_started', 'replay_authorized', 'executing', 'effect_observed',
386
+ ], 'receipt_recorded', { receipt: receiptForDurableStorage(receipt) });
387
+ await rm(this.claimPath(receipt.taskId), { force: true });
388
+ }
389
+ async selfTest() {
390
+ const path = resolve(this.directory, `.self-test-${randomUUID()}.json`);
391
+ try {
392
+ await durableJsonWrite(path, { schema: 'dharma.action-execution-journal-self-test/v1' });
393
+ const value = JSON.parse(await readFile(path, 'utf8'));
394
+ if (value.schema !== 'dharma.action-execution-journal-self-test/v1')
395
+ throw new Error('Journal self-test readback failed.');
396
+ }
397
+ finally {
398
+ await rm(path, { force: true });
399
+ }
400
+ }
401
+ }
402
+ export class FileActionDecisionReplayGuard {
403
+ directory;
404
+ constructor(directory) {
405
+ this.directory = directory;
156
406
  }
407
+ async consume(decisionId, actionDigest) {
408
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
409
+ const target = resolve(this.directory, `${decisionId}.json`);
410
+ try {
411
+ const handle = await open(target, 'wx', 0o600);
412
+ try {
413
+ await handle.writeFile(`${JSON.stringify({
414
+ decisionId,
415
+ actionDigest,
416
+ consumedAt: new Date().toISOString(),
417
+ })}\n`);
418
+ }
419
+ finally {
420
+ await handle.close();
421
+ }
422
+ return true;
423
+ }
424
+ catch (error) {
425
+ if (error.code === 'EEXIST')
426
+ return false;
427
+ throw error;
428
+ }
429
+ }
430
+ }
431
+ export function canonicalTaskActionForTask(task, actionId) {
432
+ if (!task.target.endpointId)
433
+ throw new Error('Receipt-required task target endpoint is unavailable.');
434
+ return {
435
+ schema: 'dharma.task-action/v1',
436
+ organizationId: task.organizationId,
437
+ actionId,
438
+ taskId: task.taskId,
439
+ targetEndpointId: task.target.endpointId,
440
+ workspaceId: task.workspaceId,
441
+ taskType: task.taskType,
442
+ instructions: task.instructions,
443
+ requiredSkills: task.requiredSkills,
444
+ authority: task.authority,
445
+ execution: task.execution,
446
+ acceptance: task.acceptance,
447
+ budget: task.budget,
448
+ expiresAt: task.expiresAt,
449
+ };
450
+ }
451
+ export async function authorizeEmbeddedActionDecision(input) {
452
+ const receipt = verifyEmbeddedActionDecision(input.task, input.receiver);
453
+ const envelope = input.task.actionDecision;
454
+ if (!await input.replayGuard.consume(envelope.id, envelope.actionDigest)) {
455
+ throw new Error('Action-decision receipt replay was rejected.');
456
+ }
457
+ if (receipt.outcome !== 'release') {
458
+ throw new ActionDecisionDeniedError(`Action-decision outcome ${receipt.outcome} denied execution.`, receipt);
459
+ }
460
+ return receipt;
461
+ }
462
+ export function verifyEmbeddedActionDecision(task, receiver) {
463
+ const envelope = task.actionDecision;
464
+ if (!envelope)
465
+ throw new Error('Embedded action-decision receipt is unavailable; execution is denied.');
466
+ const action = canonicalTaskActionForTask(task, envelope.receipt.actionId);
467
+ const verification = verifyActionDecisionReceipt(envelope, action, receiver.resolvePublicKey, receiver.now?.() ?? new Date());
468
+ if (!verification.ok)
469
+ throw new Error(`Action-decision receipt is invalid: ${verification.reason}.`);
470
+ return envelope.receipt;
471
+ }
472
+ function requiresActionDecisionReceipt(task) {
473
+ return task.requiredCapabilities?.includes('action_decision_receipts_v1') ?? false;
474
+ }
475
+ function interruptedActionReceipt(record, now) {
476
+ const commandResults = [{
477
+ commandId: 'runner', exitCode: null, signal: null, timedOut: false,
478
+ stdoutSha256: `sha256:${'0'.repeat(64)}`,
479
+ stderrSha256: `sha256:${createHash('sha256').update('External effect outcome is unknown after receiver restart.').digest('hex')}`,
480
+ stdout: '', stderr: 'External effect outcome is unknown after receiver restart; execution was not repeated.',
481
+ }];
482
+ return {
483
+ taskId: record.taskId, status: 'failed', worktree: record.worktree, branch: record.branch,
484
+ commandResults,
485
+ actionAcknowledgement: buildActionDecisionAcknowledgement({
486
+ taskId: record.taskId, endpointId: record.endpointId, actionDigest: record.actionDigest,
487
+ disposition: 'unknown', externalIdempotencyKey: record.externalIdempotencyKey,
488
+ result: { status: 'failed', recovery: 'abandoned_execution_claim', commandResults },
489
+ }, now),
490
+ startedAt: record.preparedAt, completedAt: now.toISOString(),
491
+ };
157
492
  }
158
493
  export async function executeTask(input) {
159
494
  verifyTaskEnvelope(input.task, input.serverPublicKey);
495
+ const taskExpiresAt = Date.parse(input.task.expiresAt);
496
+ const remainingTaskMs = () => taskExpiresAt - Date.now();
497
+ if (!Number.isFinite(taskExpiresAt) || remainingTaskMs() <= 0)
498
+ throw new Error('Task envelope expired.');
160
499
  if (input.task.organizationId !== input.policy.organizationId)
161
500
  throw new Error('Task organization does not match policy.');
162
501
  const previous = await input.receiptStore.get(input.task.taskId);
@@ -175,96 +514,226 @@ export async function executeTask(input) {
175
514
  if (!allowed.has(commandId))
176
515
  throw new Error(`Acceptance command is outside task authority: ${commandId}`);
177
516
  }
517
+ const receiptRequired = requiresActionDecisionReceipt(input.task);
518
+ const journal = input.actionExecutionJournal
519
+ ?? new FileActionExecutionJournal(resolve(input.relayStateDirectory, 'action-execution-journal'));
178
520
  const worktreeRoot = resolve(input.relayStateDirectory, 'worktrees');
179
521
  const worktree = assertContained(worktreeRoot, resolve(worktreeRoot, input.task.taskId));
180
522
  const branch = `dharma/task/${input.task.taskId}`;
181
- await mkdir(worktreeRoot, { recursive: true, mode: 0o700 });
182
- await rm(worktree, { recursive: true, force: true });
183
- await git(input.workspace, ['worktree', 'add', '--detach', worktree, 'HEAD']);
184
- await git(worktree, ['switch', '-c', branch]);
185
- const startingCommit = (await gitOutput(worktree, ['rev-parse', 'HEAD'])).trim();
523
+ let decisionReceipt = null;
524
+ let journalRecord = null;
525
+ let executionClaimed = false;
526
+ let contained = false;
186
527
  const startedAt = new Date().toISOString();
187
- const commandResults = [];
188
- let status = 'completed';
189
- try {
190
- const allowedCommands = input.task.authority.commands.map(({ commandId }) => resolveRegisteredCommand(input.policy, commandId).argv);
191
- const providerResult = await (input.providerExecutor ?? executeProviderTask)({
192
- provider: input.task.target.provider,
193
- workspace: worktree,
194
- instructions: providerInstructionsForTask(input.task),
195
- timeoutSeconds: input.task.execution.timeoutSeconds,
196
- allowedCommandArgv: allowedCommands,
197
- allowWrites: input.task.authority.writePaths.length > 0,
198
- signal: input.signal,
528
+ if (receiptRequired) {
529
+ if (!input.actionDecisions)
530
+ throw new Error('Action-decision public key resolver is unavailable; execution is denied.');
531
+ if (!input.task.target.endpointId)
532
+ throw new Error('Receipt-required task target endpoint is unavailable.');
533
+ decisionReceipt = verifyEmbeddedActionDecision(input.task, input.actionDecisions);
534
+ const prepared = await journal.prepare({
535
+ taskId: input.task.taskId, decisionId: decisionReceipt.decisionId,
536
+ endpointId: input.task.target.endpointId, actionDigest: decisionReceipt.actionDigest,
537
+ externalIdempotencyKey: decisionReceipt.decisionId, worktree, branch,
199
538
  });
200
- commandResults.push({
201
- commandId: `provider.${input.task.target.provider}`,
202
- exitCode: providerResult.exitCode,
203
- signal: providerResult.signal,
204
- timedOut: providerResult.timedOut,
205
- stdout: providerResult.stdout,
206
- stderr: providerResult.stderr,
207
- stdoutSha256: providerResult.stdoutSha256,
208
- stderrSha256: providerResult.stderrSha256,
209
- });
210
- if (providerResult.exitCode !== 0 || providerResult.timedOut)
211
- status = input.signal?.aborted ? 'cancelled' : 'failed';
212
- const trackedChanges = await gitOutput(worktree, [
213
- 'diff', '--name-only', '--diff-filter=ACDMRTUXB', startingCommit, '--',
214
- ]);
215
- const untrackedChanges = await gitOutput(worktree, ['ls-files', '--others', '--exclude-standard']);
216
- const changedPaths = `${trackedChanges}\n${untrackedChanges}`
217
- .split(/\r?\n/)
218
- .map((path) => path.trim())
219
- .filter(Boolean);
220
- if (changedPaths.length > 0) {
221
- const writes = input.task.authority.writePaths;
222
- if (writes.length === 0 || changedPaths.some((path) => !pathWithinPolicy(path, writes))) {
223
- throw new Error('Provider changed a path outside the signed task authority.');
539
+ journalRecord = prepared.record;
540
+ if (journalRecord.state === 'receipt_recorded') {
541
+ if (!journalRecord.receipt)
542
+ throw new Error('Action execution journal is missing its recorded receipt.');
543
+ await input.receiptStore.put(journalRecord.receipt);
544
+ return journalRecord.receipt;
545
+ }
546
+ const priorClaim = await journal.getClaim(input.task.taskId);
547
+ if (priorClaim) {
548
+ if (priorClaim.decisionId !== journalRecord.decisionId || priorClaim.actionDigest !== journalRecord.actionDigest) {
549
+ throw new Error('Action execution claim conflicts with the signed decision.');
550
+ }
551
+ if (journal.isClaimOwnerAlive(priorClaim))
552
+ throw new Error('Action execution is already in progress.');
553
+ const recovered = interruptedActionReceipt(journalRecord, input.actionDecisions.now?.() ?? new Date());
554
+ await journal.recordReceipt(recovered);
555
+ await input.receiptStore.put(recovered);
556
+ return recovered;
557
+ }
558
+ if (journalRecord.state === 'replay_authorization_started'
559
+ || journalRecord.state === 'executing'
560
+ || journalRecord.state === 'effect_observed') {
561
+ const recovered = interruptedActionReceipt(journalRecord, input.actionDecisions.now?.() ?? new Date());
562
+ await journal.recordReceipt(recovered);
563
+ await input.receiptStore.put(recovered);
564
+ return recovered;
565
+ }
566
+ if (decisionReceipt.outcome !== 'release') {
567
+ contained = true;
568
+ }
569
+ else if (journalRecord.state === 'prepared') {
570
+ if (input.actionDecisions.replayGuard) {
571
+ journalRecord = await journal.transition(input.task.taskId, ['prepared'], 'replay_authorization_started');
572
+ if (!await input.actionDecisions.replayGuard.consume(decisionReceipt.decisionId, decisionReceipt.actionDigest)) {
573
+ throw new Error('Action-decision receipt replay was rejected.');
574
+ }
575
+ journalRecord = await journal.transition(input.task.taskId, ['replay_authorization_started'], 'replay_authorized');
576
+ }
577
+ else {
578
+ journalRecord = await journal.transition(input.task.taskId, ['prepared'], 'replay_authorized');
224
579
  }
225
580
  }
226
- for (const { commandId } of input.task.acceptance.commands) {
227
- if (status !== 'completed')
228
- break;
229
- if (input.signal?.aborted) {
230
- status = 'cancelled';
231
- break;
581
+ if (!contained) {
582
+ if (journalRecord.state !== 'replay_authorized')
583
+ throw new Error('Action execution journal is not ready for execution.');
584
+ try {
585
+ await journal.claim(journalRecord, Math.min((input.task.execution.timeoutSeconds + input.task.execution.leaseSeconds + 300) * 1_000, remainingTaskMs()));
586
+ executionClaimed = true;
587
+ }
588
+ catch (error) {
589
+ if (error.code !== 'EEXIST')
590
+ throw error;
591
+ throw new Error('Action execution is already claimed by another receiver.');
232
592
  }
233
- const command = resolveRegisteredCommand(input.policy, commandId);
234
- const cwd = command.workingDirectory
235
- ? assertPathWithinWorkspace(worktree, command.workingDirectory)
236
- : worktree;
237
- const [executable, ...argv] = command.argv;
238
- const result = await runProcess(executable, argv, {
239
- cwd,
240
- timeoutMs: Math.min(command.timeoutSeconds, input.task.execution.timeoutSeconds) * 1_000,
593
+ }
594
+ }
595
+ const commandResults = [];
596
+ let status = contained ? 'failed' : 'completed';
597
+ if (contained && decisionReceipt) {
598
+ commandResults.push({
599
+ commandId: 'runner', exitCode: null, signal: null, timedOut: false,
600
+ stdoutSha256: `sha256:${'0'.repeat(64)}`,
601
+ stderrSha256: `sha256:${createHash('sha256').update(`Action-decision outcome ${decisionReceipt.outcome} denied execution.`).digest('hex')}`,
602
+ stdout: '', stderr: `Action-decision outcome ${decisionReceipt.outcome} denied execution.`,
603
+ });
604
+ }
605
+ if (!contained) {
606
+ try {
607
+ await mkdir(worktreeRoot, { recursive: true, mode: 0o700 });
608
+ await rm(worktree, { recursive: true, force: true });
609
+ await git(input.workspace, ['worktree', 'add', '--detach', worktree, 'HEAD']);
610
+ await git(worktree, ['switch', '-c', branch]);
611
+ const startingCommit = (await gitOutput(worktree, ['rev-parse', 'HEAD'])).trim();
612
+ const allowedCommands = input.task.authority.commands.map(({ commandId }) => resolveRegisteredCommand(input.policy, commandId).argv);
613
+ const providerInstructions = providerInstructionsForTask(input.task);
614
+ const providerTimeBudgetSeconds = Math.min(input.task.execution.timeoutSeconds, Math.floor(remainingTaskMs() / 1_000));
615
+ if (providerTimeBudgetSeconds < 1)
616
+ throw new Error('Task expires before provider execution can start.');
617
+ const providerInput = {
618
+ provider: input.task.target.provider,
619
+ workspace: worktree,
620
+ instructions: providerInstructions,
621
+ timeoutSeconds: providerTimeBudgetSeconds,
622
+ allowedCommandArgv: allowedCommands,
623
+ allowWrites: input.task.authority.writePaths.length > 0,
624
+ ...(decisionReceipt ? {
625
+ externalIdempotencyKey: decisionReceipt.decisionId,
626
+ actionDigest: decisionReceipt.actionDigest,
627
+ } : {}),
241
628
  signal: input.signal,
629
+ };
630
+ const providerResult = await (input.providerExecutor ?? executeProviderTask)(providerInput);
631
+ if (remainingTaskMs() <= 0)
632
+ throw new Error('Task expired during provider execution.');
633
+ if (executionClaimed)
634
+ await journal.recordEffectObserved(input.task.taskId, providerResult);
635
+ commandResults.push({
636
+ commandId: `provider.${input.task.target.provider}`,
637
+ exitCode: providerResult.exitCode,
638
+ signal: providerResult.signal,
639
+ timedOut: providerResult.timedOut,
640
+ stdout: providerResult.stdout,
641
+ stderr: providerResult.stderr,
642
+ stdoutSha256: providerResult.stdoutSha256,
643
+ stderrSha256: providerResult.stderrSha256,
242
644
  });
243
- commandResults.push({ commandId, ...result });
244
- if (result.exitCode !== 0 || result.timedOut) {
645
+ if (providerResult.exitCode !== 0 || providerResult.timedOut)
245
646
  status = input.signal?.aborted ? 'cancelled' : 'failed';
246
- break;
647
+ const trackedChanges = await gitOutput(worktree, [
648
+ 'diff', '--name-only', '--diff-filter=ACDMRTUXB', startingCommit, '--',
649
+ ]);
650
+ const untrackedChanges = await gitOutput(worktree, ['ls-files', '--others', '--exclude-standard']);
651
+ const changedPaths = `${trackedChanges}\n${untrackedChanges}`
652
+ .split(/\r?\n/)
653
+ .map((path) => path.trim())
654
+ .filter(Boolean);
655
+ if (changedPaths.length > 0) {
656
+ const writes = input.task.authority.writePaths;
657
+ if (writes.length === 0 || changedPaths.some((path) => !pathWithinPolicy(path, writes))) {
658
+ throw new Error('Provider changed a path outside the signed task authority.');
659
+ }
660
+ }
661
+ for (const { commandId } of input.task.acceptance.commands) {
662
+ if (status !== 'completed')
663
+ break;
664
+ if (input.signal?.aborted) {
665
+ status = 'cancelled';
666
+ break;
667
+ }
668
+ const command = resolveRegisteredCommand(input.policy, commandId);
669
+ const cwd = command.workingDirectory
670
+ ? assertPathWithinWorkspace(worktree, command.workingDirectory)
671
+ : worktree;
672
+ const [executable, ...argv] = command.argv;
673
+ const acceptanceTimeBudgetMs = remainingTaskMs();
674
+ if (acceptanceTimeBudgetMs <= 0)
675
+ throw new Error('Task expired before acceptance verification.');
676
+ const result = await runProcess(executable, argv, {
677
+ cwd,
678
+ timeoutMs: Math.min(command.timeoutSeconds * 1_000, input.task.execution.timeoutSeconds * 1_000, acceptanceTimeBudgetMs),
679
+ signal: input.signal,
680
+ });
681
+ if (remainingTaskMs() <= 0)
682
+ throw new Error('Task expired during acceptance verification.');
683
+ commandResults.push({ commandId, ...result });
684
+ if (result.exitCode !== 0 || result.timedOut) {
685
+ status = input.signal?.aborted ? 'cancelled' : 'failed';
686
+ break;
687
+ }
247
688
  }
248
689
  }
690
+ catch (error) {
691
+ status = input.signal?.aborted ? 'cancelled' : 'failed';
692
+ commandResults.push({
693
+ commandId: 'runner', exitCode: null, signal: null, timedOut: false,
694
+ stdoutSha256: `sha256:${'0'.repeat(64)}`,
695
+ stderrSha256: `sha256:${createHash('sha256').update(String(error)).digest('hex')}`,
696
+ stdout: '', stderr: error instanceof Error ? error.message : String(error),
697
+ });
698
+ }
249
699
  }
250
- catch (error) {
251
- status = input.signal?.aborted ? 'cancelled' : 'failed';
700
+ if (receiptRequired && !contained && status === 'completed' && input.task.authority.network !== 'deny') {
701
+ const message = 'Provider completion cannot prove an external network effect without a provider-specific effect receipt.';
702
+ status = 'failed';
252
703
  commandResults.push({
253
704
  commandId: 'runner', exitCode: null, signal: null, timedOut: false,
254
705
  stdoutSha256: `sha256:${'0'.repeat(64)}`,
255
- stderrSha256: `sha256:${createHash('sha256').update(String(error)).digest('hex')}`,
256
- stdout: '', stderr: error instanceof Error ? error.message : String(error),
706
+ stderrSha256: `sha256:${createHash('sha256').update(message).digest('hex')}`,
707
+ stdout: '', stderr: message,
257
708
  });
258
709
  }
710
+ const actionAcknowledgement = decisionReceipt && input.task.target.endpointId
711
+ ? buildActionDecisionAcknowledgement({
712
+ taskId: input.task.taskId,
713
+ endpointId: input.task.target.endpointId,
714
+ actionDigest: decisionReceipt.actionDigest,
715
+ disposition: contained ? 'contained' : status === 'completed' ? 'executed' : 'unknown',
716
+ externalIdempotencyKey: decisionReceipt.decisionId,
717
+ result: {
718
+ status,
719
+ commandResults: commandResults.map(({ commandId, exitCode, signal, timedOut, stdoutSha256, stderrSha256 }) => ({
720
+ commandId, exitCode, signal, timedOut, stdoutSha256, stderrSha256,
721
+ })),
722
+ },
723
+ }, input.actionDecisions?.now?.() ?? new Date())
724
+ : undefined;
259
725
  const receipt = {
260
726
  taskId: input.task.taskId,
261
727
  status,
262
728
  worktree,
263
729
  branch,
264
730
  commandResults,
731
+ ...(actionAcknowledgement ? { actionAcknowledgement } : {}),
265
732
  startedAt,
266
733
  completedAt: new Date().toISOString(),
267
734
  };
735
+ if (receiptRequired)
736
+ await journal.recordReceipt(receipt);
268
737
  await input.receiptStore.put(receipt);
269
738
  return receipt;
270
739
  }