@yeaft/webchat-agent 1.0.531 → 1.0.532

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.
@@ -2,12 +2,14 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { mkdirSync, realpathSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
+ import { WorkCenterResourceControl, WorkCenterResourceStopError } from './resource-control.js';
5
6
  import { normalizeEvidence, normalizeOutputs } from './evidence.js';
6
- import { normalizeContractPatch } from './completion-contract.js';
7
+ import { currentGoalRuns, deriveGoalProgress, hasContradictoryEvidence, validGoalChecks } from './goal-state.js';
8
+ import { assertCoordinatorContractAuthority, normalizeContractPatch } from './completion-contract.js';
7
9
  import { normalizeActionCheckpoint } from './action-checkpoint.js';
8
10
  import { currentActionInputEventIds, runMatchesActionIdentity } from './action-identity.js';
9
11
  import { isDynamicWorkItem, usesLegacyGraph } from './execution-mode.js';
10
- import { normalizeDynamicCompletion } from './dynamic-coordination.js';
12
+ import { normalizeDynamicCompletion, normalizeDynamicGoalRefs } from './dynamic-coordination.js';
11
13
  import { canonicalActionInstruction, withoutActionInputContext } from './workflow.js';
12
14
  import {
13
15
  WORK_CENTER_SCHEMA_VERSION,
@@ -815,6 +817,7 @@ export class WorkItemStore {
815
817
  this.db.exec('PRAGMA synchronous = NORMAL;');
816
818
  this.db.exec('PRAGMA foreign_keys = ON;');
817
819
  this.#initSchema();
820
+ this.resourceControl = new WorkCenterResourceControl(this);
818
821
  this.recoverCoordinatorMailbox();
819
822
  this.recoverCoordinatorProviderTurns();
820
823
  this.recoverOperations();
@@ -822,6 +825,22 @@ export class WorkItemStore {
822
825
  this.recoverInterruptedCoordinatorTurns();
823
826
  }
824
827
 
828
+ getExecutionControl(id) { return this.resourceControl.atomic(() => this.resourceControl.snapshot(id)); }
829
+ isExecutionStopped(id) { return this.resourceControl.stopped(id); }
830
+ reserveWorkItemRequest(request) { return this.resourceControl.reserve(request); }
831
+ settleCoordinatorRequest(id, usage, complete = true) { return this.resourceControl.atomic(() => this.resourceControl.settleCoordinator(id, usage, complete)); }
832
+ settleWorkItemRequest(id, usage, complete = true) { return this.resourceControl.settle(id, usage, complete); }
833
+ extendExecutionBudget(id, revision, additions) { return this.resourceControl.extend(id, revision, additions); }
834
+ stopExecution(id, code, details) { return this.resourceControl.stop(id, code, details); }
835
+ canAutomaticallyCoordinate(id, { userMessage = false } = {}) {
836
+ const row = this.db.prepare('SELECT status FROM work_items WHERE id = ?').get(id);
837
+ if (!row || ['done', 'cancelled'].includes(row.status) || this.isExecutionStopped(id)) return false;
838
+ const control = this.getExecutionControl(id);
839
+ // A failed Action may be closed without retrying it. Enforce lifetime
840
+ // attempts at Action claim, not before a Coordinator can inspect its result.
841
+ return userMessage || this.now() >= control.retryAfter;
842
+ }
843
+
825
844
  #initSchema() {
826
845
  this.db.exec(`
827
846
  CREATE TABLE IF NOT EXISTS schema_meta (
@@ -1353,6 +1372,8 @@ export class WorkItemStore {
1353
1372
 
1354
1373
  claimCoordinatorTurn(workItemId, turnId, owner, leaseMs = 60_000) {
1355
1374
  return withTransaction(this.db, () => {
1375
+ const assistant = this.getWorkItem(workItemId)?.messages?.find(message => message.turnId === turnId && message.role === 'assistant');
1376
+ if (!this.canAutomaticallyCoordinate(workItemId, { userMessage: !assistant?.automatic && !assistant?.recovery })) return null;
1356
1377
  const now = this.now();
1357
1378
  const row = this.db.prepare(`SELECT * FROM coordinator_mailbox_entries
1358
1379
  WHERE work_item_id = ? AND json_extract(payload, '$.turnId') = ?
@@ -1405,6 +1426,7 @@ export class WorkItemStore {
1405
1426
 
1406
1427
  claimCoordinatorMailbox(workItemId, owner, leaseMs = 60_000) {
1407
1428
  return withTransaction(this.db, () => {
1429
+ if (!this.canAutomaticallyCoordinate(workItemId)) return null;
1408
1430
  const now = this.now();
1409
1431
  const row = this.db.prepare(`SELECT * FROM coordinator_mailbox_entries
1410
1432
  WHERE work_item_id = ? AND (status = 'pending'
@@ -1536,7 +1558,7 @@ export class WorkItemStore {
1536
1558
  }
1537
1559
 
1538
1560
  #activeCoordinatorMailboxClaim(coordinatorTurnId, claim = {}) {
1539
- if (!claim.mailboxId || !claim.ownerBootId || !Number.isInteger(Number(claim.claimEpoch))) return null;
1561
+ if (!claim?.mailboxId || !claim.ownerBootId || !Number.isInteger(Number(claim.claimEpoch))) return null;
1540
1562
  return this.db.prepare(`SELECT * FROM coordinator_mailbox_entries WHERE id = ? AND status = 'claimed'
1541
1563
  AND claim_owner = ? AND claim_epoch = ? AND lease_expires_at > ?
1542
1564
  AND json_extract(payload, '$.turnId') = ?`).get(
@@ -1566,10 +1588,29 @@ export class WorkItemStore {
1566
1588
  return row ? this.#mapCoordinatorProviderTurn(row) : null;
1567
1589
  }
1568
1590
 
1591
+ isActiveCoordinatorProviderTurn(id, claim = {}) {
1592
+ const turn = this.getCoordinatorProviderTurn(id);
1593
+ if (!turn || turn.status !== 'dispatching' || turn.claimOwner !== claim.ownerBootId
1594
+ || turn.claimEpoch !== Number(claim.claimEpoch)
1595
+ || !this.#activeCoordinatorMailboxClaim(turn.coordinatorTurnId, claim)
1596
+ || this.isExecutionStopped(turn.workItemId)) return false;
1597
+ const item = this.getWorkItem(turn.workItemId);
1598
+ return !!item && !['done', 'cancelled'].includes(item.status);
1599
+ }
1600
+
1569
1601
  dispatchCoordinatorProviderTurn(id, claim = {}) {
1570
1602
  return withTransaction(this.db, () => {
1571
1603
  const existing = this.getCoordinatorProviderTurn(id);
1572
- if (!existing || !this.#activeCoordinatorMailboxClaim(existing.coordinatorTurnId, claim)) return null;
1604
+ if (!existing || !['prepared', 'dispatching'].includes(existing.status)
1605
+ || !this.#activeCoordinatorMailboxClaim(existing.coordinatorTurnId, claim)) return null;
1606
+ const reservation = this.reserveWorkItemRequest({
1607
+ id: existing.status === 'prepared' ? id : `${id}:retry:${randomUUID()}`, workItemId: existing.workItemId,
1608
+ kind: 'coordinator', request: existing.requestBody });
1609
+ if (!reservation.allowed) return null;
1610
+ if (existing.status === 'dispatching') {
1611
+ for (const earlier of this.resourceControl.coordinatorRequestIds(id).slice(1)) this.settleWorkItemRequest(earlier, null, false);
1612
+ return existing;
1613
+ }
1573
1614
  const now = this.now();
1574
1615
  const changed = this.db.prepare(`UPDATE coordinator_provider_turns SET status = 'dispatching',
1575
1616
  dispatched_at = ?, updated_at = ? WHERE id = ? AND status = 'prepared'
@@ -1593,6 +1634,7 @@ export class WorkItemStore {
1593
1634
  stringify(response), responseHash, now, now, id, requestHash,
1594
1635
  claim.ownerBootId, Number(claim.claimEpoch),
1595
1636
  );
1637
+ if (Number(changed.changes) === 1) this.resourceControl.settleCoordinator(id, response?.usage);
1596
1638
  return Number(changed.changes) === 1 ? this.getCoordinatorProviderTurn(id) : null;
1597
1639
  });
1598
1640
  }
@@ -2133,6 +2175,7 @@ export class WorkItemStore {
2133
2175
  const turn = this.getEngineTurn(turnId);
2134
2176
  if (!turn || turn.ownerBootId !== ownerBootId || turn.leaseEpoch !== leaseEpoch) return null;
2135
2177
  if (!this.#activeRunRow(turn.runId, ownerBootId, leaseEpoch, true)) return null;
2178
+ if (this.isExecutionStopped(turn.workItemId)) return null;
2136
2179
  if (turn.status === 'dispatching') return turn;
2137
2180
  if (turn.status !== 'prepared') return null;
2138
2181
  const now = this.now();
@@ -3114,7 +3157,7 @@ export class WorkItemStore {
3114
3157
  }
3115
3158
  return workItems.map(workItem => {
3116
3159
  const actions = actionsByWorkItem.get(workItem.id) || [];
3117
- return graphExecutionState({ ...workItem, actions, runs: runsByWorkItem.get(workItem.id) || [] }, actions);
3160
+ return graphExecutionState({ ...workItem, executionControl: this.getExecutionControl(workItem.id), actions, runs: runsByWorkItem.get(workItem.id) || [] }, actions);
3118
3161
  });
3119
3162
  }
3120
3163
 
@@ -3123,11 +3166,13 @@ export class WorkItemStore {
3123
3166
  if (!workItem) return null;
3124
3167
  const detail = {
3125
3168
  ...workItem,
3169
+ executionControl: this.getExecutionControl(id),
3126
3170
  actions: this.db.prepare('SELECT * FROM actions WHERE work_item_id = ? ORDER BY sequence').all(id).map(mapAction),
3127
3171
  runs: this.db.prepare('SELECT * FROM runs WHERE work_item_id = ? ORDER BY started_at DESC').all(id).map(mapRun),
3128
3172
  planConflicts: this.listPlanConflicts(id),
3129
3173
  events: this.db.prepare('SELECT * FROM events WHERE work_item_id = ? ORDER BY id DESC LIMIT 500').all(id).map(mapEvent),
3130
3174
  };
3175
+ detail.goalProgress = deriveGoalProgress(detail);
3131
3176
  return graphExecutionState(detail, detail.actions);
3132
3177
  }
3133
3178
 
@@ -3212,7 +3257,7 @@ export class WorkItemStore {
3212
3257
  );
3213
3258
  if (!mailbox) return null;
3214
3259
  const workItem = this.getWorkItem(mailbox.work_item_id);
3215
- if (!isDynamicWorkItem(workItem) || ['done', 'cancelled'].includes(workItem.status)) return null;
3260
+ if (!isDynamicWorkItem(workItem) || !this.canAutomaticallyCoordinate(workItem.id)) return null;
3216
3261
  const latest = (workItem.messages || []).at(-1);
3217
3262
  if (latest?.role === 'assistant' && latest.status === 'thinking') return null;
3218
3263
  const now = this.now();
@@ -3304,6 +3349,7 @@ export class WorkItemStore {
3304
3349
  }
3305
3350
  const workItem = this.getWorkItem(id);
3306
3351
  if (!workItem) return null;
3352
+ if (this.isExecutionStopped(id)) throw new WorkCenterResourceStopError(this.getExecutionControl(id).stopReason);
3307
3353
  if (['done', 'cancelled'].includes(workItem.status)) {
3308
3354
  throw new Error(`WorkItem in ${workItem.status} cannot accept Coordinator messages`);
3309
3355
  }
@@ -3436,6 +3482,7 @@ export class WorkItemStore {
3436
3482
  if (!this.#activeCoordinatorMailboxClaim(turnId, claim)) return null;
3437
3483
  const workItem = this.getWorkItem(expected.workItemId);
3438
3484
  if (!workItem) return null;
3485
+ if (this.isExecutionStopped(workItem.id)) throw new WorkCenterResourceStopError(this.getExecutionControl(workItem.id).stopReason);
3439
3486
  if (workItem.revision !== expected.revision
3440
3487
  || workItem.planRevision !== expected.planRevision
3441
3488
  || workItem.ledgerRevision !== expected.ledgerRevision
@@ -3454,6 +3501,13 @@ export class WorkItemStore {
3454
3501
  }
3455
3502
  const recovery = coordinatorRecoveryIdentity(persistedRecovery);
3456
3503
  const decision = result?.decision || {};
3504
+ const userOriginated = messages[assistantIndex]?.userOriginated === true
3505
+ && messages[assistantIndex]?.automatic !== true;
3506
+ assertCoordinatorContractAuthority(decision.contractPatch, userOriginated);
3507
+ assertCoordinatorContractAuthority(result?.mutation?.contractPatch, userOriginated);
3508
+ if (expected.userOriginated === true && !userOriginated) {
3509
+ throw new Error('Coordinator user-originated authority does not match the persisted turn');
3510
+ }
3457
3511
  const now = this.now();
3458
3512
  const activeActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
3459
3513
  AND status NOT IN ('completed', 'closed', 'superseded', 'cancelled') ORDER BY sequence`).all(workItem.id).map(mapAction);
@@ -3542,17 +3596,17 @@ export class WorkItemStore {
3542
3596
  type: 'coordinator-guidance', role: 'user', summary: instruction, evidence: [],
3543
3597
  }];
3544
3598
  const nextAction = {
3545
- ...action, context, generation: action.generation + 1,
3599
+ ...action, context, generation: action.generation + 1, contractRevision: workItem.revision,
3546
3600
  };
3547
3601
  nextAction.instruction = canonicalActionInstruction(workItem, nextAction, context);
3548
3602
  const specHash = actionSpecHash(nextAction);
3549
3603
  const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
3550
3604
  current_run_id = NULL, lease_epoch = lease_epoch + ?, context = ?, instruction = ?,
3551
- generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
3605
+ generation = generation + 1, contract_revision = ?, spec_hash = ?, identity_history = ?, result_run_id = NULL,
3552
3606
  workspace = NULL, updated_at = ? WHERE id = ? AND generation = ?
3553
3607
  AND status NOT IN ('completed', 'superseded', 'cancelled')`).run(
3554
3608
  action.status === 'running' ? 1 : 0,
3555
- stringify(context), nextAction.instruction, specHash,
3609
+ stringify(context), nextAction.instruction, nextAction.contractRevision, specHash,
3556
3610
  stringify(actionIdentityHistory(action, nextAction.generation, specHash)),
3557
3611
  now, action.id, action.generation,
3558
3612
  );
@@ -3684,20 +3738,22 @@ export class WorkItemStore {
3684
3738
  turnId, result, expected, workItem, messages, assistantIndex, activeActions, now,
3685
3739
  }) {
3686
3740
  const decision = result?.decision || {};
3687
- if (expected.automatic === true && result?.mutation?.contractPatch?.deliveryTarget) {
3688
- throw new Error('Automatic Work Center Coordinator delivery target changes are forbidden');
3741
+ const decisionPatch = normalizeContractPatch(decision.contractPatch);
3742
+ const mutationPatch = normalizeContractPatch(result?.mutation?.contractPatch);
3743
+ if (JSON.stringify(decisionPatch) !== JSON.stringify(mutationPatch) && mutationPatch) {
3744
+ throw new Error('Coordinator mutation contractPatch must match the user-originated decision');
3689
3745
  }
3690
- if (decision.contractPatch?.deliveryTarget && expected.userOriginated !== true) {
3691
- throw new Error('WorkItem delivery target confirmation requires a user-originated Coordinator turn');
3746
+ if (decisionPatch && !['create_actions', 'request_human'].includes(decision.kind)) {
3747
+ throw new Error('Contract refinement requires create_actions or request_human');
3692
3748
  }
3693
- if (decision.contractPatch?.deliveryTarget && decision.kind !== 'request_human') {
3749
+ if (decisionPatch?.deliveryTarget && decision.kind !== 'request_human') {
3694
3750
  throw new Error('WorkItem delivery target confirmation requires a user-originated request_human decision');
3695
3751
  }
3696
3752
  let affectedActionIds = [];
3697
3753
  let nextStatus = workItem.status;
3698
3754
  let currentActionId = workItem.currentActionId;
3699
3755
  let finalResult = null;
3700
- const contractPatch = normalizeContractPatch(decision.contractPatch);
3756
+ const contractPatch = decisionPatch;
3701
3757
  if (decision.kind === 'create_actions'
3702
3758
  && !workItem.deliveryTarget
3703
3759
  && (result?.mutation?.createdActions || []).some(action => (
@@ -3705,11 +3761,18 @@ export class WorkItemStore {
3705
3761
  ))) {
3706
3762
  throw new Error('WorkItem delivery target must be confirmed before creating mutating or delivery Actions');
3707
3763
  }
3708
- if (contractPatch) {
3709
- if (contractPatch.title) workItem.title = contractPatch.title;
3710
- if (contractPatch.goal) workItem.goal = contractPatch.goal;
3711
- if (contractPatch.acceptanceCriteria) workItem.acceptanceCriteria = contractPatch.acceptanceCriteria;
3712
- if (contractPatch.deliveryTarget) workItem.deliveryTarget = contractPatch.deliveryTarget;
3764
+ const refined = { ...workItem, ...(contractPatch || {}) };
3765
+ const contractChanged = ['title', 'goal', 'deliveryTarget', 'acceptanceCriteria']
3766
+ .some(key => JSON.stringify(refined[key]) !== JSON.stringify(workItem[key]));
3767
+ if (contractChanged) {
3768
+ this.#invalidateExecution(workItem, 'superseded', 'superseded', 'User refined the WorkItem contract', now);
3769
+ this.db.prepare(`UPDATE work_items SET title = ?, goal = ?, acceptance_criteria = ?,
3770
+ delivery_target = ?, revision = revision + 1, updated_at = ? WHERE id = ? AND revision = ?`).run(
3771
+ refined.title, refined.goal, stringify(refined.acceptanceCriteria), refined.deliveryTarget,
3772
+ now, workItem.id, workItem.revision,
3773
+ );
3774
+ workItem = this.getWorkItem(workItem.id);
3775
+ activeActions = [];
3713
3776
  }
3714
3777
 
3715
3778
  if (decision.kind === 'create_actions') {
@@ -3720,7 +3783,11 @@ export class WorkItemStore {
3720
3783
  if (!mutation || !Array.isArray(mutation.createdActions) || mutation.createdActions.length === 0) {
3721
3784
  throw new Error('Dynamic Coordinator Action creation is missing a validated mutation');
3722
3785
  }
3723
- for (const closure of mutation.closeActions || []) {
3786
+ const goalDetail = this.getWorkItemDetail(workItem.id);
3787
+ for (const candidate of mutation.createdActions) {
3788
+ normalizeDynamicGoalRefs(candidate.brief?.goalRefs, goalDetail, goalDetail.actions);
3789
+ }
3790
+ for (const closure of (contractChanged ? [] : mutation.closeActions || [])) {
3724
3791
  const action = activeActions.find(candidate => candidate.id === closure.actionId);
3725
3792
  if (!action || !['waiting', 'failed'].includes(action.status)) {
3726
3793
  throw new Error('Dynamic Coordinator close target changed before apply');
@@ -3738,7 +3805,7 @@ export class WorkItemStore {
3738
3805
  actionGeneration: action.generation,
3739
3806
  });
3740
3807
  }
3741
- for (const actionId of mutation.supersedeActionIds || []) {
3808
+ for (const actionId of (contractChanged ? [] : mutation.supersedeActionIds || [])) {
3742
3809
  const action = activeActions.find(candidate => candidate.id === actionId);
3743
3810
  if (!action || !['ready', 'waiting', 'failed'].includes(action.status)) {
3744
3811
  throw new Error('Dynamic Coordinator supersede target changed before apply');
@@ -3749,18 +3816,11 @@ export class WorkItemStore {
3749
3816
  AND status IN ('ready', 'waiting', 'failed')`).run(now, action.id, action.generation);
3750
3817
  if (Number(changed.changes) !== 1) throw new Error('Dynamic Coordinator lost an Action fence');
3751
3818
  }
3752
- const patch = mutation.contractPatch || null;
3753
- const title = patch?.title ?? workItem.title;
3754
- const goal = patch?.goal ?? workItem.goal;
3755
- const criteria = patch?.acceptanceCriteria ?? workItem.acceptanceCriteria;
3756
- const contractChanged = title !== workItem.title || goal !== workItem.goal
3757
- || JSON.stringify(criteria) !== JSON.stringify(workItem.acceptanceCriteria);
3758
3819
  const snapshot = { ...workItem.workflowSnapshot, workItemType: mutation.workItemType };
3759
- const changedPlan = this.db.prepare(`UPDATE work_items SET title = ?, goal = ?,
3760
- acceptance_criteria = ?, workflow_snapshot = ?, revision = revision + ?,
3820
+ const changedPlan = this.db.prepare(`UPDATE work_items SET workflow_snapshot = ?,
3761
3821
  plan_revision = plan_revision + 1, updated_at = ? WHERE id = ? AND revision = ?
3762
3822
  AND plan_revision = ? AND ledger_revision = ? AND coordinator_revision = ?`).run(
3763
- title, goal, stringify(criteria), stringify(snapshot), contractChanged ? 1 : 0,
3823
+ stringify(snapshot),
3764
3824
  now, workItem.id, workItem.revision, workItem.planRevision,
3765
3825
  workItem.ledgerRevision, workItem.coordinatorRevision,
3766
3826
  );
@@ -3788,14 +3848,14 @@ export class WorkItemStore {
3788
3848
  const context = [...withoutActionInputContext(action.context), {
3789
3849
  type: 'coordinator-guidance', role: 'user', summary: guidance, evidence: [],
3790
3850
  }];
3791
- const candidate = { ...action, context, generation: action.generation + 1 };
3851
+ const candidate = { ...action, context, generation: action.generation + 1, contractRevision: workItem.revision };
3792
3852
  candidate.instruction = canonicalActionInstruction(workItem, candidate, context);
3793
3853
  const specHash = actionSpecHash(candidate);
3794
3854
  const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
3795
3855
  current_run_id = NULL, context = ?, instruction = ?, generation = generation + 1,
3796
- spec_hash = ?, identity_history = ?, result_run_id = NULL, workspace = NULL, updated_at = ?
3856
+ contract_revision = ?, spec_hash = ?, identity_history = ?, result_run_id = NULL, workspace = NULL, updated_at = ?
3797
3857
  WHERE id = ? AND generation = ? AND status IN ('ready', 'waiting', 'failed')`).run(
3798
- stringify(context), candidate.instruction, specHash,
3858
+ stringify(context), candidate.instruction, candidate.contractRevision, specHash,
3799
3859
  stringify(actionIdentityHistory(action, candidate.generation, specHash)),
3800
3860
  now, action.id, action.generation,
3801
3861
  );
@@ -3838,12 +3898,10 @@ export class WorkItemStore {
3838
3898
  throw new Error('WorkItem has an unsafe blocking Operation and cannot complete');
3839
3899
  }
3840
3900
  finalResult = normalizeDynamicCompletion(decision.completion, workItem.acceptanceCriteria);
3841
- const canonicalRuns = new Map(this.db.prepare(`SELECT r.* FROM runs r JOIN actions a ON a.id = r.action_id
3842
- WHERE r.work_item_id = ? AND r.status = 'completed' AND a.result_run_id = r.id`).all(workItem.id)
3843
- .map(row => {
3844
- const run = mapRun(row);
3845
- return [run.id, run];
3846
- }));
3901
+ const detail = this.getWorkItemDetail(workItem.id);
3902
+ const progress = deriveGoalProgress(detail);
3903
+ const canonicalRuns = new Map(currentGoalRuns(detail)
3904
+ .filter(run => run.status === 'completed').map(run => [run.id, run]));
3847
3905
  const criteria = Array.isArray(workItem.acceptanceCriteria) ? workItem.acceptanceCriteria : [];
3848
3906
  for (const runId of finalResult.evidenceRunIds) {
3849
3907
  const run = canonicalRuns.get(runId);
@@ -3851,17 +3909,18 @@ export class WorkItemStore {
3851
3909
  if (run.evidence.length === 0) {
3852
3910
  throw new Error(`Completion evidence Run has no concrete evidence: ${runId}`);
3853
3911
  }
3854
- if (!Array.isArray(run.acceptanceChecks) || run.acceptanceChecks.length !== criteria.length
3855
- || run.acceptanceChecks.some((check, index) => (
3856
- check?.criterion !== criteria[index] || !check?.status || !String(check?.evidence || '').trim()
3857
- ))) {
3912
+ if (!validGoalChecks(run, criteria)) {
3858
3913
  throw new Error(`Completion evidence Run has incomplete acceptance checks: ${runId}`);
3859
3914
  }
3915
+ if (hasContradictoryEvidence(run)) {
3916
+ throw new Error(`Completion evidence Run has contradictory evidence: ${runId}`);
3917
+ }
3860
3918
  }
3861
3919
  finalResult.outputs = [];
3862
3920
  const seenOutputs = new Set();
3863
3921
  for (const runId of finalResult.evidenceRunIds) {
3864
3922
  for (const output of canonicalRuns.get(runId)?.outputs || []) {
3923
+ if (['failed', 'error', 'pending'].includes(output.status)) continue;
3865
3924
  const key = `${output.kind}\u0000${output.ref}`;
3866
3925
  if (seenOutputs.has(key)) continue;
3867
3926
  seenOutputs.add(key);
@@ -3873,16 +3932,27 @@ export class WorkItemStore {
3873
3932
  pull_request: 'pr',
3874
3933
  merge: 'commit',
3875
3934
  }[workItem.deliveryTarget];
3876
- if (!requiredOutputKind) {
3935
+ if (!requiredOutputKind && workItem.deliveryTarget !== 'response') {
3877
3936
  throw new Error('WorkItem delivery target must be confirmed before completion');
3878
3937
  }
3879
- if (!finalResult.outputs.some(output => output.kind === requiredOutputKind)) {
3938
+ if (requiredOutputKind && !finalResult.outputs.some(output => output.kind === requiredOutputKind)) {
3880
3939
  throw new Error(`WorkItem completion requires a canonical ${requiredOutputKind} output for delivery target ${workItem.deliveryTarget}`);
3881
3940
  }
3941
+ if (!finalResult.evidenceRunIds.some(runId => progress.delivery.evidenceRunIds.includes(runId))) {
3942
+ throw new Error('Completion delivery requires current canonical evidence after the latest workspace change');
3943
+ }
3944
+ if (workItem.deliveryTarget === 'response') {
3945
+ finalResult.responses = finalResult.evidenceRunIds
3946
+ .filter(runId => progress.delivery.evidenceRunIds.includes(runId))
3947
+ .map(runId => ({ runId, summary: canonicalRuns.get(runId).summary, evidence: canonicalRuns.get(runId).evidence }));
3948
+ if (!finalResult.responses.length) throw new Error('Response delivery requires a canonical completed Run summary with evidence and valid checks');
3949
+ }
3950
+ if (progress.remainingCriteria.length) {
3951
+ throw new Error('Completion has unmet or contradictory current goal criteria');
3952
+ }
3882
3953
  for (const [index, acceptanceResult] of finalResult.acceptanceResults.entries()) {
3883
3954
  const provesCriterion = acceptanceResult.evidenceRunIds.some(runId => (
3884
- canonicalRuns.get(runId)?.acceptanceChecks?.[index]?.criterion === criteria[index]
3885
- && canonicalRuns.get(runId)?.acceptanceChecks?.[index]?.status === 'passed'
3955
+ progress.criteria[index].evidenceRunIds.includes(runId)
3886
3956
  ));
3887
3957
  if (!provesCriterion) {
3888
3958
  throw new Error(`Completion criterion lacks a passing canonical Run check: ${criteria[index]}`);
@@ -3915,12 +3985,12 @@ export class WorkItemStore {
3915
3985
  const changed = this.db.prepare(`UPDATE work_items SET messages = ?,
3916
3986
  coordinator_revision = coordinator_revision + 1, status = ?, current_action_id = ?,
3917
3987
  current_run_id = NULL, final_result = COALESCE(final_result, ?), title = ?, goal = ?,
3918
- acceptance_criteria = ?, delivery_target = ?, revision = revision + ?, updated_at = ?
3988
+ acceptance_criteria = ?, delivery_target = ?, updated_at = ?
3919
3989
  WHERE id = ? AND coordinator_revision = ? AND revision = ? AND plan_revision = ?
3920
3990
  AND ledger_revision = ? AND status NOT IN ('done', 'cancelled')`).run(
3921
3991
  stringify(messages), nextStatus, currentActionId, finalResult ? stringify(finalResult) : null,
3922
3992
  workItem.title, workItem.goal, stringify(workItem.acceptanceCriteria), workItem.deliveryTarget,
3923
- contractPatch ? 1 : 0, now, workItem.id, current.coordinatorRevision, current.revision,
3993
+ now, workItem.id, current.coordinatorRevision, current.revision,
3924
3994
  current.planRevision, current.ledgerRevision,
3925
3995
  );
3926
3996
  if (Number(changed.changes) !== 1) throw new Error('Dynamic Coordinator completion lost its turn fence');
@@ -3958,7 +4028,9 @@ export class WorkItemStore {
3958
4028
  messages[index],
3959
4029
  `coordinator:turn:${turnId}:assistant`,
3960
4030
  );
3961
- const dynamicAutomatic = isDynamicWorkItem(workItem) && expected.automatic === true;
4031
+ if (!expected.interrupted) this.resourceControl.coordinatorFailed(workItem.id, turnId);
4032
+ const dynamicAutomatic = isDynamicWorkItem(workItem) && expected.automatic === true
4033
+ && !this.isExecutionStopped(workItem.id);
3962
4034
  if (dynamicAutomatic) {
3963
4035
  if (!this.releaseCoordinatorMailboxClaim(
3964
4036
  claim.mailboxId, claim.ownerBootId, claim.claimEpoch,
@@ -4226,16 +4298,37 @@ export class WorkItemStore {
4226
4298
  });
4227
4299
  }
4228
4300
 
4229
- resumeWorkItemAtomic(id, expectedRevision, makeInitialAction) {
4301
+ resumeWorkItemAtomic(id, expectedRevision, makeInitialAction, executionControlRevision) {
4230
4302
  return withTransaction(this.db, () => {
4231
4303
  const workItem = this.getWorkItem(id);
4232
4304
  if (!workItem) return null;
4233
4305
  if (!Number.isInteger(expectedRevision) || workItem.revision !== expectedRevision) {
4234
4306
  throw new Error('WorkItem changed before it was resumed; refresh and try again');
4235
4307
  }
4236
- if (workItem.status !== 'cancelled') {
4308
+ const stopped = this.isExecutionStopped(id);
4309
+ if (workItem.status !== 'cancelled' && !stopped) {
4237
4310
  throw new Error(`WorkItem in ${workItem.status} cannot be resumed`);
4238
4311
  }
4312
+ this.resourceControl.resume(id, executionControlRevision);
4313
+ // Resume changes the execution epoch, never the goal contract. Retire old
4314
+ // Coordinator claims so recovery cannot apply pre-stop guidance later.
4315
+ const nowResumed = this.now();
4316
+ const messages = (workItem.messages || []).map(message => {
4317
+ if (message.role !== 'assistant' || message.status !== 'thinking') return message;
4318
+ const retired = { ...message, status: 'failed', error: 'Explicit user resume superseded this Coordinator turn', updatedAt: nowResumed };
4319
+ this.#appendConversationEntry(id, retired, `coordinator:turn:${message.turnId}:assistant`);
4320
+ this.db.prepare(`UPDATE coordinator_mailbox_entries SET status = 'cancelled',
4321
+ claim_owner = NULL, lease_expires_at = NULL, updated_at = ?
4322
+ WHERE work_item_id = ? AND json_extract(payload, '$.turnId') = ? AND status IN ('pending', 'claimed')`)
4323
+ .run(nowResumed, id, message.turnId);
4324
+ return retired;
4325
+ });
4326
+ this.db.prepare(`UPDATE work_items SET coordinator_revision = coordinator_revision + 1,
4327
+ messages = ?, updated_at = ? WHERE id = ?`).run(stringify(messages), nowResumed, id);
4328
+ if (stopped) {
4329
+ this.#invalidateExecution(workItem, 'cancelled', 'cancelled', 'Explicit user resume', this.now());
4330
+ this.db.prepare("UPDATE work_items SET status = 'cancelled' WHERE id = ?").run(id);
4331
+ }
4239
4332
 
4240
4333
  const now = this.now();
4241
4334
  const cancelledActions = this.db.prepare(`SELECT * FROM actions WHERE work_item_id = ?
@@ -4259,7 +4352,7 @@ export class WorkItemStore {
4259
4352
  }
4260
4353
  this.enqueueCoordinatorMailbox(id, 'work_item_resumed', {
4261
4354
  trigger: { workItemId: id, revision: expectedRevision },
4262
- }, `dynamic:resume:${id}:${expectedRevision}`);
4355
+ }, `dynamic:resume:${id}:${this.getExecutionControl(id).revision}`);
4263
4356
  this.appendEvent(id, 'work_item.resumed', {
4264
4357
  supersededActionIds: cancelledActions.map(action => action.id),
4265
4358
  });
@@ -4554,7 +4647,9 @@ export class WorkItemStore {
4554
4647
  )
4555
4648
  )
4556
4649
  ORDER BY a.updated_at ASC, a.sequence ASC`).all();
4557
- const row = rows.find(candidate => !this.#hasBlockingOperation(candidate.work_item_id, candidate.id));
4650
+ const row = rows.find(candidate => !this.isExecutionStopped(candidate.work_item_id)
4651
+ && !this.#hasBlockingOperation(candidate.work_item_id, candidate.id)
4652
+ && this.resourceControl.canAttempt(mapAction(candidate)));
4558
4653
  if (!row) return null;
4559
4654
  const now = this.now();
4560
4655
  let action = mapAction(row);
@@ -4654,7 +4749,7 @@ export class WorkItemStore {
4654
4749
  if (!row) return null;
4655
4750
  const workItem = this.getWorkItem(row.work_item_id);
4656
4751
  if (usesLegacyGraph(workItem) || isDynamicWorkItem(workItem)) return row;
4657
- return workItem?.status === 'running' && workItem.currentActionId === row.action_id
4752
+ return (workItem?.status === 'running' || this.isExecutionStopped(workItem?.id)) && workItem.currentActionId === row.action_id
4658
4753
  && workItem.currentRunId === row.id ? row : null;
4659
4754
  }
4660
4755
 
@@ -4735,15 +4830,19 @@ export class WorkItemStore {
4735
4830
  if (Number(actionChanged.changes) !== 1) throw new Error('Run interruption lost the Action fence');
4736
4831
  const activeWorkItem = this.getWorkItem(active.work_item_id);
4737
4832
  const concurrentMode = usesLegacyGraph(activeWorkItem) || isDynamicWorkItem(activeWorkItem);
4833
+ // Resource denial changes only admission, not the owning Run's right to
4834
+ // persist its final progress and release its fences during watcher stop.
4835
+ const stopped = this.isExecutionStopped(active.work_item_id);
4836
+ const nextStatus = stopped || !retryable ? 'needs_attention' : 'ready';
4738
4837
  const itemChanged = concurrentMode
4739
4838
  ? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
4740
- updated_at = ? WHERE id = ? AND status = 'running'`).run(
4741
- retryable ? 'ready' : 'needs_attention', action.id, now, active.work_item_id,
4839
+ updated_at = ? WHERE id = ? AND status IN ('running', ?)`).run(
4840
+ nextStatus, action.id, now, active.work_item_id, stopped ? 'needs_attention' : 'running',
4742
4841
  )
4743
4842
  : this.db.prepare(`UPDATE work_items SET status = ?, current_run_id = NULL,
4744
- updated_at = ? WHERE id = ? AND status = 'running' AND current_action_id = ?
4843
+ updated_at = ? WHERE id = ? AND status IN ('running', ?) AND current_action_id = ?
4745
4844
  AND current_run_id = ?`).run(
4746
- retryable ? 'ready' : 'needs_attention', now, active.work_item_id, action.id, runId,
4845
+ nextStatus, now, active.work_item_id, stopped ? 'needs_attention' : 'running', action.id, runId,
4747
4846
  );
4748
4847
  if (Number(itemChanged.changes) !== 1) throw new Error('Run interruption lost the WorkItem fence');
4749
4848
  this.appendEvent(active.work_item_id, 'run.interrupted', { retryable, reason }, {
@@ -5186,7 +5285,7 @@ export class WorkItemStore {
5186
5285
  } else {
5187
5286
  changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
5188
5287
  current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ? WHERE id = ? AND current_run_id = ?
5189
- AND current_action_id = ? AND status = 'running' AND revision = ?`).run(
5288
+ AND current_action_id = ? AND status IN ('running', 'needs_attention') AND revision = ?`).run(
5190
5289
  workItemStatus, currentActionId, ledgerIncrement, now, workItem.id, runId, action.id, nextWorkItem.revision,
5191
5290
  );
5192
5291
  }
@@ -5300,6 +5399,7 @@ export class WorkItemStore {
5300
5399
  WHERE json_extract(payload, '$.turnId') = ? AND status != 'acked'`).run(now, now, turnId);
5301
5400
  }
5302
5401
  }
5402
+ this.resourceControl.coordinatorFailed(row.id, turnId);
5303
5403
  this.appendEvent(row.id, 'coordinator.turn_interrupted', {
5304
5404
  turnId: messages[index].turnId || null,
5305
5405
  error: messages[index].error,
@@ -1,4 +1,5 @@
1
1
  import { sessionMessageQuotePrompt } from '../session-message-quote.js';
2
+ import { isDynamicWorkItem } from './execution-mode.js';
2
3
  import { renderSessionContextSnapshot } from './session-context.js';
3
4
 
4
5
  export const BUILT_IN_ACTION_TYPES = Object.freeze([
@@ -40,7 +41,7 @@ const DEFAULT_STAGE_INSTRUCTIONS = Object.freeze({
40
41
  operate: 'Perform the operational change with explicit preconditions, safety fences, observability, and rollback handling. Verify the live or simulated postcondition from authoritative state, avoid destructive shortcuts, and record the exact evidence needed for handoff.',
41
42
  deliver: 'Deliver only an approved result using the repository release policy. Recheck the reviewed commit and remote state, run required final verification on the immutable delivery tree, publish only the requested artifacts, and report commit, tag, deployment, and residual-risk evidence.',
42
43
  write: 'Produce the requested written deliverable for its intended audience. Use the available evidence, preserve required terminology and structure, avoid unsupported claims, and verify the result against every acceptance criterion.',
43
- create_vp: 'Create one persistent specialist VP only when no existing VP can execute a required capability. Author a narrow role, traits, and persona with the CreateWorkItemVp tool, then report the new VP id as evidence. Do not clone an existing VP or create a generic replacement.',
44
+ create_vp: 'Create a persistent specialist VP only when it is itself an explicitly requested deliverable. A missing skill label is not a reason to expand roles: prefer an existing VP with a task-specific brief, and request human help for unavailable tools or authorization. Author a narrow role, traits, and persona with the CreateWorkItemVp tool, then report the new VP id as evidence. Do not clone an existing VP or create a generic replacement.',
44
45
  custom: 'Complete the Action objective using repository facts and the WorkItem contract. State the approach, handle relevant risks and boundary conditions, produce the requested artifact or change, verify the result, and return concrete evidence plus any residual uncertainty.',
45
46
  });
46
47
 
@@ -58,7 +59,7 @@ const DEFAULT_ACTION_BRIEFS = Object.freeze({
58
59
  operate: ['Perform the requested operational change safely.', 'Check preconditions, apply safety fences, preserve rollback options, and verify authoritative state.', 'A verified operational postcondition with handoff and rollback evidence.'],
59
60
  deliver: ['Deliver the approved Work Item result using repository release policy.', 'Recheck immutable reviewed state, run final gates, and publish only the requested artifacts.', 'A traceable delivery with commit, artifact, deployment, and residual-risk evidence.'],
60
61
  write: ['Produce the written deliverable requested by this Action.', 'Use available evidence, the intended audience, and the required terminology and structure.', 'A complete written artifact verified against the acceptance criteria.'],
61
- create_vp: ['Create the missing specialist VP required by this Work Item.', 'Have the assigned existing VP author a narrow persistent role and persona with the dedicated VP creation tool.', 'A new Agent-local VP whose identity and capability can be selected by later Actions.'],
62
+ create_vp: ['Create the persistent specialist VP explicitly requested as this Work Item deliverable.', 'Have the assigned existing VP author a narrow persistent role and persona with the dedicated VP creation tool.', 'A new Agent-local VP whose identity and capability can be selected by later Actions.'],
62
63
  custom: ['Complete the domain-specific objective defined for this Action.', 'Use repository facts, handle relevant risks and boundaries, and verify the produced result.', 'The requested artifact or change with concrete evidence and residual uncertainty.'],
63
64
  });
64
65
 
@@ -774,7 +775,10 @@ export function actionInstruction(stage, workItem, context = [], sessionContextB
774
775
  const policy = stage.instruction || defaultWorkCenterStageInstruction(stage.type);
775
776
  const brief = normalizeActionBrief(stage.brief || stage, stage.type);
776
777
  const contract = `Action type: ${stage.type}\nWhat to do:\n${brief.objective}\n\nHow to do it:\n${brief.approach}\n\nExpected result:\n${brief.expectedOutcome}`;
777
- return `${common}\n\n${policy}\n\n${contract}`;
778
+ const dynamicRules = isDynamicWorkItem(workItem)
779
+ ? `\n\nGoal-driven execution: complete this useful unit end to end, including local tools and risk-appropriate tests. Do not invent research/design/test/review/delivery stages or create roles to work around missing skills. Keep the user contract unchanged; report real tool or authorization blockers. Delivery target: ${workItem.deliveryTarget || 'unconfirmed'}. For response delivery put the substantive result/report in summary with evidence and acceptance checks; no artificial file, PR, or packaging Action is needed.${stage.brief?.goalRefs ? `\nCurrent goal references: ${JSON.stringify(stage.brief.goalRefs)}` : ''}${stage.brief?.rationale ? `\nWhy this Action: ${stage.brief.rationale}` : ''}`
780
+ : '';
781
+ return `${common}\n\n${policy}\n\n${contract}${dynamicRules}`;
778
782
  }
779
783
 
780
784
  export function withoutActionInputContext(context, preserveInputIds = []) {