@yeaft/webchat-agent 1.0.559 → 1.0.562

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.559",
3
+ "version": "1.0.562",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -254,6 +254,7 @@ export async function handleWorkCenterRequest(msg) {
254
254
  op,
255
255
  ok: false,
256
256
  error: err?.message || String(err),
257
+ ...(err?.code === 'WORK_CENTER_INPUT_STALE' ? { errorCode: err.code } : {}),
257
258
  _requestUserId: msg._requestUserId || null,
258
259
  });
259
260
  }
@@ -164,6 +164,19 @@ export class WorkflowController {
164
164
  return detail;
165
165
  }
166
166
 
167
+ startScheduled(id, scheduledAt) {
168
+ return this.store.startWorkItemAtomic(id, workItem => {
169
+ const action = initialActionFor(workItem);
170
+ if (workItem.reuseMemory === false) return action;
171
+ const context = this.store.getReusableContext(workItem.workDir, workItem.id);
172
+ return {
173
+ ...action,
174
+ context,
175
+ instruction: actionInstruction(action, workItem, context, renderSessionContextSnapshot(workItem.sessionContext)),
176
+ };
177
+ }, { scheduledAt });
178
+ }
179
+
167
180
  update(id, patch) {
168
181
  const updated = this.store.updateWorkItemAtomic(id, patch, initialActionFor);
169
182
  if (!updated) throw new Error(`WorkItem not found: ${id}`);
@@ -251,11 +264,14 @@ export class WorkflowController {
251
264
  throw new Error('actionId, revision, and generation are required for Action input');
252
265
  }
253
266
  const graphMode = workItem.workflowSnapshot?.executionMode === 'graph';
267
+ const concurrentMode = graphMode || workItem.coordinationMode === 'dynamic';
254
268
  const targetMatches = targetAction?.workItemId === id
255
269
  && targetAction.generation === expectedGeneration
256
- && (graphMode || workItem.currentActionId === input.actionId);
270
+ && (concurrentMode || workItem.currentActionId === input.actionId);
257
271
  if (!targetMatches || workItem.revision !== input.revision) {
258
- throw new Error('Action changed before input was applied; refresh and try again');
272
+ const error = new Error('Action changed before input was applied; refresh and try again');
273
+ error.code = 'WORK_CENTER_INPUT_STALE';
274
+ throw error;
259
275
  }
260
276
  if (['ready', 'running'].includes(targetAction.status)) {
261
277
  if (targetAction.status === 'running' && addedAttachmentCount > 0) {
@@ -269,7 +285,9 @@ export class WorkflowController {
269
285
  }, input.attachments, input.addedAttachments, input.clientMessageId, quote);
270
286
  }
271
287
  if (!['waiting', 'failed'].includes(targetAction.status)) {
272
- throw new Error(`Action in ${targetAction.status} cannot accept input`);
288
+ const error = new Error(`Action in ${targetAction.status} cannot accept input`);
289
+ error.code = 'WORK_CENTER_INPUT_STALE';
290
+ throw error;
273
291
  }
274
292
  return this.retry(id, {
275
293
  answer: text,
@@ -20,6 +20,7 @@ const MIGRATIONS = [
20
20
  ['37-run-acceptance-checks', migrateRunAcceptanceChecks],
21
21
  ['38-action-closure-and-outputs', migrateActionClosureAndOutputs],
22
22
  ['39-action-creation-source', migrateActionCreationSource],
23
+ ['40-work-item-schedules', migrateWorkItemSchedules],
23
24
  ];
24
25
 
25
26
  const MIGRATION_ALIASES = new Map([
@@ -566,6 +567,21 @@ function migrateActionClosureAndOutputs(db) {
566
567
  `);
567
568
  }
568
569
 
570
+ function migrateWorkItemSchedules(db) {
571
+ for (const [column, definition] of [
572
+ ['schedule_status', 'TEXT'],
573
+ ['scheduled_for', 'INTEGER'],
574
+ ['schedule_triggered_at', 'INTEGER'],
575
+ ]) {
576
+ if (!hasColumn(db, 'work_items', column)) {
577
+ db.exec(`ALTER TABLE work_items ADD COLUMN ${column} ${definition}`);
578
+ }
579
+ }
580
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_work_items_schedule_due
581
+ ON work_items(schedule_status, scheduled_for)
582
+ WHERE schedule_status = 'scheduled'`);
583
+ }
584
+
569
585
  function migrateActionCreationSource(db) {
570
586
  if (!hasColumn(db, 'actions', 'creation_source')) {
571
587
  db.exec("ALTER TABLE actions ADD COLUMN creation_source TEXT NOT NULL DEFAULT 'legacy'");
@@ -1094,6 +1094,7 @@ export function projectWorkItemDetail(detail, options = {}) {
1094
1094
  executionControl: detail.executionControl,
1095
1095
  executionStats: combinedExecutionStats(detail),
1096
1096
  reuseMemory: detail.reuseMemory !== false,
1097
+ schedule: detail.schedule || null,
1097
1098
  deliveryTarget: ['response', 'workspace_files', 'pull_request', 'merge'].includes(detail.deliveryTarget)
1098
1099
  ? detail.deliveryTarget : null,
1099
1100
  waitingReason: sanitizeDiagnosticText(waitingReason(detail), MAX_ACTION_DIAGNOSTIC_CHARS),
@@ -1204,6 +1205,7 @@ export function projectWorkItemSummary(detail) {
1204
1205
  executionStats: combinedExecutionStats(detail),
1205
1206
  executionControl: detail.executionControl,
1206
1207
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1208
+ schedule: detail.schedule || null,
1207
1209
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1208
1210
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
1209
1211
  createdAt: detail.createdAt,
@@ -1244,6 +1246,7 @@ export function projectWorkItemSummary(detail) {
1244
1246
  currentAction: projectCurrentActionSummary(action, projectedAction),
1245
1247
  actionStats: projectActionStats(detail, null),
1246
1248
  origin: detail.origin?.sessionId ? { sessionId: detail.origin.sessionId } : null,
1249
+ schedule: detail.schedule || null,
1247
1250
  linkedSessionIds: Array.isArray(detail.linkedSessionIds) ? detail.linkedSessionIds : [],
1248
1251
  attachmentCount: Array.isArray(detail.attachments) ? detail.attachments.length : 0,
1249
1252
  createdAt: detail.createdAt,
@@ -97,6 +97,7 @@ export class WorkCenterService {
97
97
  constructor(options) {
98
98
  const yeaftDir = requiredString(options?.yeaftDir, 'yeaftDir');
99
99
  this.yeaftDir = yeaftDir;
100
+ this.now = typeof options.now === 'function' ? options.now : () => Date.now();
100
101
  this.settingsReader = options.settingsReader || readWorkCenterSettings;
101
102
  this.settingsWriter = options.settingsWriter || writeWorkCenterSettings;
102
103
  this.runtimeInfoProvider = typeof options.runtimeInfoProvider === 'function'
@@ -246,7 +247,13 @@ export class WorkCenterService {
246
247
  root: this.attachmentRoot,
247
248
  workItemId,
248
249
  });
249
- const shouldStart = payload.start === undefined ? settings.startImmediately : payload.start !== false;
250
+ const scheduledFor = payload.scheduledFor == null || payload.scheduledFor === ''
251
+ ? null : Number(payload.scheduledFor);
252
+ if (scheduledFor != null && (!Number.isSafeInteger(scheduledFor) || scheduledFor <= this.now())) {
253
+ throw new Error('scheduledFor must be in the future');
254
+ }
255
+ const shouldStart = scheduledFor == null
256
+ && (payload.start === undefined ? settings.startImmediately : payload.start !== false);
250
257
  const goal = requiredString(payload.goal, 'goal');
251
258
  const explicitTitle = payload.titleSource !== 'coordinator_pending' && typeof payload.title === 'string'
252
259
  ? payload.title.trim() : '';
@@ -289,6 +296,10 @@ export class WorkCenterService {
289
296
  ? normalizeSessionContextSnapshot(payload.sessionContext)
290
297
  : [],
291
298
  attachments,
299
+ schedule: scheduledFor == null ? null : {
300
+ status: payload.scheduleEnabled === false ? 'paused' : 'scheduled',
301
+ scheduledFor,
302
+ },
292
303
  start: false,
293
304
  });
294
305
  let detail = this.#requiredItem(workItemId);
@@ -313,6 +324,13 @@ export class WorkCenterService {
313
324
  this.#emit({ type: 'work_item.updated', workItem: detail });
314
325
  return detail;
315
326
  }
327
+ case 'update_schedule': {
328
+ const id = requiredString(payload.id, 'id');
329
+ const detail = this.store.updateWorkItemSchedule(id, payload.schedule || payload);
330
+ if (!detail) throw new Error(`WorkItem not found: ${id}`);
331
+ this.#emit({ type: 'work_item.schedule_updated', workItem: detail });
332
+ return detail;
333
+ }
316
334
  case 'start': {
317
335
  const detail = this.controller.start(requiredString(payload.id, 'id'));
318
336
  if (detail.coordinationMode === DYNAMIC_COORDINATION_MODE) {
@@ -715,7 +733,16 @@ export class WorkCenterService {
715
733
  }
716
734
  }
717
735
 
736
+ #scanSchedules() {
737
+ const now = this.now();
738
+ for (const id of this.store.listDueScheduledWorkItemIds(now)) {
739
+ const detail = this.controller.startScheduled(id, now);
740
+ if (detail) this.#emit({ type: 'work_item.schedule_triggered', workItem: detail });
741
+ }
742
+ }
743
+
718
744
  #scanRecoveries() {
745
+ this.#scanSchedules();
719
746
  this.#scanCoordinatorProviderRecoveries();
720
747
  this.#scanDynamicCoordinatorWakes();
721
748
  this.#scanFailureRecoveries();
@@ -112,6 +112,11 @@ function mapWorkItem(row) {
112
112
  coordinationMode: row.coordination_mode || 'legacy',
113
113
  finalResult: parseJson(row.final_result, null),
114
114
  deliveryTarget: row.delivery_target || null,
115
+ schedule: row.schedule_status ? {
116
+ status: row.schedule_status,
117
+ scheduledFor: Number(row.scheduled_for) || null,
118
+ triggeredAt: Number(row.schedule_triggered_at) || null,
119
+ } : null,
115
120
  title: row.title,
116
121
  titleSource: row.title_source || 'explicit',
117
122
  requirement: row.requirement ?? row.goal,
@@ -858,6 +863,9 @@ export class WorkItemStore {
858
863
  coordination_mode TEXT NOT NULL DEFAULT 'legacy',
859
864
  final_result TEXT,
860
865
  delivery_target TEXT,
866
+ schedule_status TEXT,
867
+ scheduled_for INTEGER,
868
+ schedule_triggered_at INTEGER,
861
869
  title TEXT NOT NULL,
862
870
  title_source TEXT NOT NULL DEFAULT 'explicit',
863
871
  requirement TEXT,
@@ -1022,6 +1030,16 @@ export class WorkItemStore {
1022
1030
 
1023
1031
  // The feature shipped first as an unmerged PR, but keep the store tolerant
1024
1032
  // of databases created by review builds.
1033
+ if (!hasColumn(this.db, 'work_items', 'schedule_status')) {
1034
+ this.db.exec('ALTER TABLE work_items ADD COLUMN schedule_status TEXT');
1035
+ }
1036
+ if (!hasColumn(this.db, 'work_items', 'scheduled_for')) {
1037
+ this.db.exec('ALTER TABLE work_items ADD COLUMN scheduled_for INTEGER');
1038
+ }
1039
+ if (!hasColumn(this.db, 'work_items', 'schedule_triggered_at')) {
1040
+ this.db.exec('ALTER TABLE work_items ADD COLUMN schedule_triggered_at INTEGER');
1041
+ }
1042
+
1025
1043
  if (!hasColumn(this.db, 'work_items', 'workspace_key')) {
1026
1044
  withTransaction(this.db, () => {
1027
1045
  this.db.exec("ALTER TABLE work_items ADD COLUMN workspace_key TEXT NOT NULL DEFAULT ''");
@@ -1944,7 +1962,9 @@ export class WorkItemStore {
1944
1962
  ? ['ready', 'running', 'waiting', 'needs_attention']
1945
1963
  : ['ready', 'running'];
1946
1964
  if (!inputStatuses.includes(workItem.status)) {
1947
- throw new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
1965
+ const error = new Error(`WorkItem in ${workItem.status} cannot accept Action input`);
1966
+ error.code = 'WORK_CENTER_INPUT_STALE';
1967
+ throw error;
1948
1968
  }
1949
1969
  const action = this.getAction(expected.actionId);
1950
1970
  const actionMatches = action?.workItemId === id
@@ -1956,7 +1976,9 @@ export class WorkItemStore {
1956
1976
  || (activeRun?.status === 'running' && activeRun.acceptingInput !== false
1957
1977
  && runMatchesActionIdentity(activeRun, action));
1958
1978
  if (!actionMatches || !runMatches || workItem.revision !== expected.revision) {
1959
- throw new Error('Action changed before input was applied; refresh and try again');
1979
+ const error = new Error('Action changed before input was applied; refresh and try again');
1980
+ error.code = 'WORK_CENTER_INPUT_STALE';
1981
+ throw error;
1960
1982
  }
1961
1983
  const projectedAttachments = (Array.isArray(addedAttachments) ? addedAttachments : []).map(attachment => ({
1962
1984
  id: attachment.id,
@@ -1998,7 +2020,7 @@ export class WorkItemStore {
1998
2020
  const changedAction = this.db.prepare(`UPDATE actions SET context = ?, instruction = ?, attempt = 0,
1999
2021
  generation = generation + 1, spec_hash = ?, identity_history = ?, result_run_id = NULL,
2000
2022
  workspace = NULL, updated_at = ? WHERE id = ? AND status = 'ready' AND current_run_id IS NULL
2001
- AND generation = ? AND spec_hash = ?`).run(
2023
+ AND generation = ? AND spec_hash = ? AND contract_revision = ?`).run(
2002
2024
  stringify(context),
2003
2025
  nextAction.instruction,
2004
2026
  nextAction.specHash,
@@ -2007,9 +2029,12 @@ export class WorkItemStore {
2007
2029
  action.id,
2008
2030
  action.generation,
2009
2031
  action.specHash,
2032
+ action.contractRevision,
2010
2033
  );
2011
2034
  if (Number(changedAction.changes) !== 1) {
2012
- throw new Error('Action changed before input was applied; refresh and try again');
2035
+ const error = new Error('Action changed before input was applied; refresh and try again');
2036
+ error.code = 'WORK_CENTER_INPUT_STALE';
2037
+ throw error;
2013
2038
  }
2014
2039
  this.#supersedePendingActionInputs(
2015
2040
  [action],
@@ -2400,14 +2425,16 @@ export class WorkItemStore {
2400
2425
  const workspaceKey = canonicalWorkspaceKey(input.workDir);
2401
2426
  this.db.prepare(`INSERT INTO work_items
2402
2427
  (id, revision, execution_schema_version, ledger_revision, coordination_mode, final_result, delivery_target,
2403
- title, title_source, requirement, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
2428
+ schedule_status, scheduled_for, schedule_triggered_at, title, title_source, requirement, goal, acceptance_criteria, workflow_template, workflow_snapshot, status,
2404
2429
  current_action_id, current_run_id, work_dir, workspace_key, reuse_memory, origin, linked_session_ids,
2405
2430
  session_context, attachments, created_at, updated_at)
2406
- VALUES (?, 1, ?, 0, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
2431
+ VALUES (?, 1, ?, 0, ?, NULL, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
2407
2432
  id,
2408
2433
  Number.isInteger(input.executionSchemaVersion) ? input.executionSchemaVersion : 2,
2409
2434
  input.coordinationMode || 'legacy',
2410
2435
  input.deliveryTarget || null,
2436
+ input.schedule?.status || null,
2437
+ input.schedule?.scheduledFor || null,
2411
2438
  input.title,
2412
2439
  input.titleSource === 'coordinator_pending' ? 'coordinator_pending' : 'explicit',
2413
2440
  input.goal,
@@ -3092,6 +3119,32 @@ export class WorkItemStore {
3092
3119
  }));
3093
3120
  }
3094
3121
 
3122
+ listDueScheduledWorkItemIds(now = this.now()) {
3123
+ return this.db.prepare(`SELECT id FROM work_items
3124
+ WHERE schedule_status = 'scheduled' AND scheduled_for <= ? AND status = 'draft'
3125
+ ORDER BY scheduled_for, id`).all(now).map(row => row.id);
3126
+ }
3127
+
3128
+ updateWorkItemSchedule(id, input = {}) {
3129
+ return withTransaction(this.db, () => {
3130
+ const row = this.db.prepare('SELECT * FROM work_items WHERE id = ?').get(id);
3131
+ if (!row) return null;
3132
+ if (row.status !== 'draft' || !['scheduled', 'paused'].includes(row.schedule_status)) {
3133
+ throw new Error('Only pending scheduled WorkItems can be changed');
3134
+ }
3135
+ const scheduledFor = input.scheduledFor == null ? Number(row.scheduled_for) : Number(input.scheduledFor);
3136
+ if (!Number.isSafeInteger(scheduledFor) || scheduledFor <= this.now()) {
3137
+ throw new Error('scheduledFor must be in the future');
3138
+ }
3139
+ const status = input.enabled === false ? 'paused' : 'scheduled';
3140
+ const now = this.now();
3141
+ this.db.prepare(`UPDATE work_items SET schedule_status = ?, scheduled_for = ?,
3142
+ revision = revision + 1, updated_at = ? WHERE id = ?`).run(status, scheduledFor, now, id);
3143
+ this.appendEvent(id, 'work_item.schedule_updated', { status, scheduledFor });
3144
+ return this.getWorkItemDetail(id);
3145
+ });
3146
+ }
3147
+
3095
3148
  listWorkItems(filters = {}) {
3096
3149
  const where = [];
3097
3150
  const values = [];
@@ -4485,10 +4538,14 @@ export class WorkItemStore {
4485
4538
  });
4486
4539
  }
4487
4540
 
4488
- startWorkItemAtomic(id, makeInitialAction) {
4541
+ startWorkItemAtomic(id, makeInitialAction, options = {}) {
4489
4542
  return withTransaction(this.db, () => {
4490
4543
  const workItem = this.getWorkItem(id);
4491
4544
  if (!workItem) return null;
4545
+ const scheduledAt = Number(options.scheduledAt);
4546
+ const scheduledStart = Number.isSafeInteger(scheduledAt);
4547
+ if (scheduledStart && (workItem.status !== 'draft' || workItem.schedule?.status !== 'scheduled'
4548
+ || workItem.schedule.scheduledFor > scheduledAt)) return null;
4492
4549
  if (['done', 'cancelled'].includes(workItem.status)) {
4493
4550
  throw new Error(`Cannot start WorkItem in ${workItem.status}`);
4494
4551
  }
@@ -4498,13 +4555,18 @@ export class WorkItemStore {
4498
4555
  throw new Error(`WorkItem in ${workItem.status} must be resumed with retry`);
4499
4556
  }
4500
4557
  const now = this.now();
4558
+ if (scheduledStart) {
4559
+ this.db.prepare(`UPDATE work_items SET schedule_status = 'triggered',
4560
+ schedule_triggered_at = ?, revision = revision + 1, updated_at = ? WHERE id = ?`)
4561
+ .run(scheduledAt, now, id);
4562
+ }
4501
4563
  if (isDynamicWorkItem(workItem)) {
4502
4564
  this.db.prepare(`UPDATE work_items SET status = 'running', current_action_id = NULL,
4503
4565
  current_run_id = NULL, updated_at = ? WHERE id = ?`).run(now, id);
4504
4566
  this.enqueueCoordinatorMailbox(id, 'work_item_started', {
4505
4567
  trigger: { workItemId: id },
4506
4568
  }, `dynamic:start:${id}:${workItem.revision}`);
4507
- this.appendEvent(id, 'work_item.started');
4569
+ this.appendEvent(id, 'work_item.started', scheduledStart ? { scheduled: true } : {});
4508
4570
  return this.getWorkItemDetail(id);
4509
4571
  }
4510
4572
  const action = this.#insertAction(id, {
@@ -4513,7 +4575,7 @@ export class WorkItemStore {
4513
4575
  }, this.#nextSequence(id), now);
4514
4576
  this.db.prepare(`UPDATE work_items SET status = 'ready', current_action_id = ?,
4515
4577
  current_run_id = NULL, updated_at = ? WHERE id = ?`).run(action.id, now, id);
4516
- this.appendEvent(id, 'work_item.started', {}, { actionId: action.id });
4578
+ this.appendEvent(id, 'work_item.started', scheduledStart ? { scheduled: true } : {}, { actionId: action.id });
4517
4579
  return this.getWorkItemDetail(id);
4518
4580
  });
4519
4581
  }
@@ -4536,12 +4598,15 @@ export class WorkItemStore {
4536
4598
  }
4537
4599
  const workItem = this.getWorkItem(id);
4538
4600
  if (!workItem) return null;
4601
+ const dynamicMode = isDynamicWorkItem(workItem);
4539
4602
  const graphMode = usesLegacyGraph(workItem);
4540
- const retryableWorkItemStatuses = graphMode
4603
+ const retryableWorkItemStatuses = graphMode || dynamicMode
4541
4604
  ? ['ready', 'running', 'waiting', 'needs_attention']
4542
4605
  : ['waiting', 'needs_attention'];
4543
4606
  if (!retryableWorkItemStatuses.includes(workItem.status)) {
4544
- throw new Error(`WorkItem in ${workItem.status} does not need retry`);
4607
+ const error = new Error(`WorkItem in ${workItem.status} does not need retry`);
4608
+ error.code = 'WORK_CENTER_INPUT_STALE';
4609
+ throw error;
4545
4610
  }
4546
4611
  let previous = workItem.currentActionId ? this.getAction(workItem.currentActionId) : null;
4547
4612
  if (options.expected) {
@@ -4553,10 +4618,12 @@ export class WorkItemStore {
4553
4618
  const hasExpectedGeneration = Number.isInteger(expectedGeneration) && expectedGeneration > 0;
4554
4619
  const expectedMatches = expectedAction?.workItemId === id
4555
4620
  && allowedExpectedStatuses.includes(expectedAction.status)
4556
- && (graphMode || workItem.currentActionId === options.expected.actionId)
4557
- && (hasExpectedGeneration ? expectedAction.generation === expectedGeneration : !graphMode);
4621
+ && (graphMode || dynamicMode || workItem.currentActionId === options.expected.actionId)
4622
+ && (hasExpectedGeneration ? expectedAction.generation === expectedGeneration : !(graphMode || dynamicMode));
4558
4623
  if (!expectedMatches || workItem.revision !== options.expected.revision) {
4559
- throw new Error('Action changed before input was applied; refresh and try again');
4624
+ const error = new Error('Action changed before input was applied; refresh and try again');
4625
+ error.code = 'WORK_CENTER_INPUT_STALE';
4626
+ throw error;
4560
4627
  }
4561
4628
  previous = expectedAction;
4562
4629
  }
@@ -4565,7 +4632,7 @@ export class WorkItemStore {
4565
4632
  AND status != 'running' ORDER BY ended_at DESC, started_at DESC LIMIT 1`).get(id, previous.id))
4566
4633
  : null;
4567
4634
  const now = this.now();
4568
- const revision = options.expected ? workItem.revision + 1 : workItem.revision;
4635
+ const revision = options.expected && !dynamicMode ? workItem.revision + 1 : workItem.revision;
4569
4636
  const replacement = {
4570
4637
  ...makeAction(workItem, previous, previousRun),
4571
4638
  contractRevision: previous?.contractRevision ?? workItem.revision,
@@ -4573,6 +4640,55 @@ export class WorkItemStore {
4573
4640
  const inputEvent = options.inputEvent && typeof options.inputEvent === 'object'
4574
4641
  ? options.inputEvent
4575
4642
  : null;
4643
+ if (dynamicMode) {
4644
+ if (!previous) throw new Error('Dynamic WorkItem retry target is missing');
4645
+ const candidate = {
4646
+ ...previous,
4647
+ ...replacement,
4648
+ id: previous.id,
4649
+ generation: previous.generation + 1,
4650
+ attempt: 0,
4651
+ currentRunId: null,
4652
+ resultRunId: null,
4653
+ workspace: null,
4654
+ };
4655
+ candidate.instruction = canonicalActionInstruction(workItem, candidate, candidate.context);
4656
+ candidate.specHash = actionSpecHash(candidate);
4657
+ const changed = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = 0,
4658
+ current_run_id = NULL, context = ?, instruction = ?, generation = generation + 1,
4659
+ spec_hash = ?, identity_history = ?, result_run_id = NULL, workspace = NULL, updated_at = ?
4660
+ WHERE id = ? AND work_item_id = ? AND status IN ('waiting', 'failed')
4661
+ AND current_run_id IS NULL AND generation = ? AND spec_hash = ? AND contract_revision = ?`).run(
4662
+ stringify(candidate.context), candidate.instruction, candidate.specHash,
4663
+ stringify(actionIdentityHistory(previous, candidate.generation, candidate.specHash)),
4664
+ now, previous.id, id, previous.generation, previous.specHash, previous.contractRevision,
4665
+ );
4666
+ if (Number(changed.changes) !== 1) {
4667
+ const error = new Error('Action changed before input was applied; refresh and try again');
4668
+ error.code = 'WORK_CENTER_INPUT_STALE';
4669
+ throw error;
4670
+ }
4671
+ const changedWorkItem = this.db.prepare(`UPDATE work_items SET attachments = ?, updated_at = ?
4672
+ WHERE id = ? AND revision = ? AND status NOT IN ('done', 'cancelled')`).run(
4673
+ stringify(Array.isArray(options.attachments) ? options.attachments : workItem.attachments),
4674
+ now, id, workItem.revision,
4675
+ );
4676
+ if (Number(changedWorkItem.changes) !== 1) {
4677
+ const error = new Error('Action changed before input was applied; refresh and try again');
4678
+ error.code = 'WORK_CENTER_INPUT_STALE';
4679
+ throw error;
4680
+ }
4681
+ const action = this.getAction(previous.id);
4682
+ if (inputEvent) {
4683
+ this.appendEvent(id, 'action.input_added', inputEvent, {
4684
+ actionId: action.id,
4685
+ actionGeneration: action.generation,
4686
+ });
4687
+ } else {
4688
+ this.appendEvent(id, 'work_item.retried', {}, { actionId: action.id });
4689
+ }
4690
+ return this.getWorkItemDetail(id);
4691
+ }
4576
4692
  if (graphMode) {
4577
4693
  if (!previous) throw new Error('WorkItem graph retry target is missing');
4578
4694
  const action = this.#resetGraphFromStage(