@gitroll/tacitt-cli 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/dist/{agent-setup-YlFIJz-h.mjs → agent-setup-LPd8WEsN.mjs} +2 -2
  3. package/dist/agent-setup-worker.mjs +5 -5
  4. package/dist/cli.mjs +19 -10
  5. package/dist/codex-title-worker.mjs +1 -1
  6. package/dist/configured-preview-worker.mjs +5 -5
  7. package/dist/configured-session-worker.mjs +5 -5
  8. package/dist/{frame-Dj5Zs6UW.mjs → frame-ByT1xayJ.mjs} +1 -1
  9. package/dist/{heap-DsE74_-n.mjs → heap-CM298Yu0.mjs} +8 -6
  10. package/dist/inspection-worker.mjs +4 -4
  11. package/dist/{job-CeSf9UCC.mjs → job-BHIwElNF.mjs} +5 -5
  12. package/dist/{locate-BWkblijS.mjs → locate-Cwv6WH8n.mjs} +25 -2
  13. package/dist/{model-f_QnDyyb.mjs → model-zvn5GUJ9.mjs} +1 -1
  14. package/dist/operation-executor.mjs +3 -3
  15. package/dist/preview-worker.mjs +1 -1
  16. package/dist/privacy-validator.mjs +1 -1
  17. package/dist/{query-CaBXQlpF.mjs → query-By41gaKm.mjs} +585 -250
  18. package/dist/{run-RA_8zZsL.mjs → run-ZQx7p4Rw.mjs} +4 -4
  19. package/dist/{src-p_uJNjV3.mjs → src-3jvMvSQn.mjs} +3 -3
  20. package/dist/{src-d7n58sbH.mjs → src-BqtdFVq7.mjs} +683 -341
  21. package/dist/{src-BBjnljUs.mjs → src-D_DfY--2.mjs} +4 -4
  22. package/dist/store-database-worker.mjs +1 -1
  23. package/dist/web/assets/index-B14e6_-z.js +174 -0
  24. package/dist/web/index.html +1 -1
  25. package/package.json +1 -1
  26. package/skills/tacitt/INDEX.md +6 -6
  27. package/skills/tacitt/SKILL.md +4 -4
  28. package/skills/tacitt/references/INDEX.md +3 -3
  29. package/skills/tacitt/references/classification.md +12 -2
  30. package/skills/tacitt/references/commands.md +60 -13
  31. package/skills/tacitt/references/journey.md +10 -2
  32. package/skills/tacitt/references/parallel-review.md +21 -0
  33. package/skills/tacitt/references/send.md +29 -8
  34. package/skills/tacitt/scripts/INDEX.md +39 -36
  35. package/skills/tacitt/scripts/journey-classification-validation.d.mts +13 -0
  36. package/skills/tacitt/scripts/journey-classification-validation.mjs +38 -0
  37. package/skills/tacitt/scripts/journey-scan-timing.d.mts +15 -0
  38. package/skills/tacitt/scripts/journey-scan-timing.mjs +153 -1
  39. package/skills/tacitt/scripts/journey-state.d.mts +6 -0
  40. package/skills/tacitt/scripts/journey-state.mjs +24 -1
  41. package/skills/tacitt/scripts/journey.d.mts +5 -0
  42. package/skills/tacitt/scripts/journey.mjs +48 -3
  43. package/skills/tacitt/scripts/privacy-validator.mjs +11 -1
  44. package/skills/tacitt/scripts/review.mjs +294 -38
  45. package/LICENSE +0 -11
  46. package/dist/web/assets/index-B6kJ6XXF.js +0 -174
@@ -7,6 +7,7 @@ import {
7
7
  phaseOf,
8
8
  uncoveredPending,
9
9
  } from './journey-state.mjs'
10
+ import { observeScanTiming } from './journey-scan-timing.mjs'
10
11
  import { startReviewStream } from './review-stream-client.mjs'
11
12
  import { createHash, randomUUID } from 'node:crypto'
12
13
  import {
@@ -19,6 +20,7 @@ import {
19
20
  readdir,
20
21
  realpath,
21
22
  rename,
23
+ stat,
22
24
  rm,
23
25
  writeFile,
24
26
  } from 'node:fs/promises'
@@ -48,13 +50,10 @@ const SLOTS = 16
48
50
  // While `--agents auto` waits, journey input (newly published sessions) is rechecked this often.
49
51
  const AUTO_RECHECK_MS = 3_000
50
52
  const PREPARATION_WAIT_MS = 60_000
51
- // `--agents auto` gathers this many free slots, waiting at most GATHER_MS after the first, so
52
- // one parent turn dispatches several children: each turn costs the parent seconds.
53
- const GATHER_SLOTS = 3
54
- const GATHER_MS = 10_000
55
- // A pack with no published answer this long after dispatch has likely lost its child (for
56
- // example to a parent restart); packs finished within about two minutes on the 2026-10-26 corpus.
57
- const STALE_MS = 300_000
53
+ // Bound from reservation, allowing the observed long tail of completed children.
54
+ const LOST_PACK_TIMEOUT_MS = 450_000
55
+ // A current draft renews liveness independently of the reservation age.
56
+ const DRAFT_HEARTBEAT_TIMEOUT_MS = 450_000
58
57
  const WRAP = 400
59
58
  // `review.terms` bounds: a value has 1–4000 code points, an exclusion reason 500.
60
59
  const TERM_CODE_POINTS = 4000
@@ -86,9 +85,21 @@ class HelperError extends Error {
86
85
  super(code)
87
86
  this.code = /^[A-Z_]{3,80}$/u.test(code) ? code : 'HELPER_FAILED'
88
87
  if (
89
- code === 'INVALID_REVIEW_SPAN' &&
90
88
  typeof field === 'string' &&
91
- /^terms\[\d+(?:,\d+){0,31}\]$/u.test(field)
89
+ ((code === 'INVALID_REVIEW_SPAN' && /^terms\[\d+(?:,\d+){0,31}\]$/u.test(field)) ||
90
+ (code === 'INVALID_SELECTION' &&
91
+ [
92
+ 'cards.append.empty',
93
+ 'cards.append.limit',
94
+ 'cards.append.duplicate-card',
95
+ 'cards.append.existing-card',
96
+ 'cards.append.duplicate-extension',
97
+ 'cards.append.unknown-card',
98
+ 'cards.append.duplicate-term',
99
+ 'cards.append.term-not-open',
100
+ 'cards.append.own-identity-card',
101
+ 'cards.append.other-person',
102
+ ].includes(field)))
92
103
  )
93
104
  this.field = field
94
105
  }
@@ -301,7 +312,17 @@ async function observe(ctx, readOnly = false) {
301
312
  ctx.state.preparationFailed = false
302
313
  if (ctx.state.reader) ctx.state.reader.waitingSince = null
303
314
  }
304
- if (!readOnly) ctx.state.completedWorks = completedWorks
315
+ if (!readOnly) {
316
+ ctx.state.completedWorks = completedWorks
317
+ ctx.state.preparationBlocked = [
318
+ ...new Set([
319
+ ...(ctx.state.preparationBlocked ?? []),
320
+ ...view.works
321
+ .filter((work) => work.status === 'blocked')
322
+ .map((work) => `${work.id}:${work.inputRevision}`),
323
+ ]),
324
+ ]
325
+ }
305
326
  const works = Object.fromEntries(
306
327
  view.works.map((work) => [work.id, { inputRevision: work.inputRevision, access: work.access }]),
307
328
  )
@@ -587,7 +608,7 @@ const withAnswer = (task, text) => {
587
608
  // Each pack names its own draft so concurrent children never share a scratch file.
588
609
  const draft = resolve(task.replace(/\.md$/u, '.draft.json'))
589
610
  const command = `${shellQuote(process.execPath)} ${shellQuote(resolve(task.replace(/\.md$/u, '.publish.mjs')))} ${shellQuote(draft)}`
590
- return `${text}\n\n## Your answer\n\nWrite the complete JSON object to ${draft} using your native file-edit tool. Publish it with this exact command:\n\n${command}\n\nThe CLI validates task coverage and every normalized term against the assigned review source before atomically publishing. Invalid source diagnostics identify normalized terms indices and a private normalized-answer receipt; read that receipt because these indices may differ from the original draft after splitting. Preserve supported findings, repair exact contiguous text, and explicitly report unsupported or context-only proposals. Never silently discard a finding to pass validation. Do not write the canonical answer yourself. Correct validation failures in your draft and retry the same command. Reply with only done after successful publication; otherwise report failure.\n`
611
+ return `${text}\n\n## Your answer\n\nAs soon as your reads return, overwrite ${draft} with {} again: the CLI treats this file as your progress signal and re-dispatches a task whose draft stops changing. Then write the complete JSON object to ${draft} using your native file-edit tool. Publish it with this exact command:\n\n${command}\n\nThe CLI validates task coverage and every normalized term against the assigned review source before atomically publishing. Invalid source diagnostics identify normalized terms indices and a private normalized-answer receipt; read that receipt because these indices may differ from the original draft after splitting. Preserve supported findings, repair exact contiguous text, and explicitly report unsupported or context-only proposals. Never silently discard a finding to pass validation. Do not write the canonical answer yourself. Correct validation failures in your draft and retry the same command. Reply with only done after successful publication; otherwise report failure.\n`
591
612
  }
592
613
  /** Validate a complete task answer before the publisher replaces any prior answer. */
593
614
  export function validatePrivacyAnswer(answer, unitRefs) {
@@ -656,7 +677,7 @@ function packPrompt(task, text) {
656
677
  const reads = plan.map(({ offset, limit }) => `(${offset},${limit})`).join(' ')
657
678
  // In the prompt itself, not a start file: given the plan indirectly, fast models read the
658
679
  // ranges one turn at a time and then reread them, and each extra turn costs seconds.
659
- return `Your FIRST message must contain exactly ${plan.length} Read tool calls issued together in parallel on ${task} with these (offset, limit): ${reads}. Do not read it any other way. Allow 24000 output tokens per read; with functions.exec, start its code with // @exec: {"max_output_tokens": 24000} and set the nested exec_command max_output_tokens to 24000. Reread a refused or truncated range in smaller parts. Then follow that task exactly: author the JSON draft with your file-edit tool and run only its exact publisher command. Work alone. Reply done after successful publication.`
680
+ return `Your FIRST message must contain exactly ${plan.length} Read tool calls issued together in parallel on ${task} with these (offset, limit): ${reads}, plus one file-edit call that creates ${resolve(task.replace(/\.md$/u, '.draft.json'))} containing {} (your progress signal: a task whose draft stops changing is re-dispatched). Do not read it any other way. Allow 24000 output tokens per read; with functions.exec, start its code with // @exec: {"max_output_tokens": 24000} and set the nested exec_command max_output_tokens to 24000. Reread a refused or truncated range in smaller parts. Then follow that task exactly: author the JSON draft with your file-edit tool and run only its exact publisher command. Work alone. Reply done after successful publication.`
660
681
  }
661
682
  /** Writes a task and its publisher and returns the agent's prompt. */
662
683
  export async function writePackTask(task, text, unitRefs, sourceBatches = []) {
@@ -908,6 +929,8 @@ async function finishPack(ctx, meta) {
908
929
  ctx.state.queue.unshift(...requeue)
909
930
  meta.units = kept
910
931
  meta.state = kept.length ? 'open' : 'empty'
932
+ // The lease starts when the task is handed out, also for a pack resumed from dispatching.
933
+ meta.dispatchedAt = new Date().toISOString()
911
934
  meta.codePoints = batches.reduce(
912
935
  (total, item) => total + item.unit.coreCodePoints + item.unit.contextCodePoints,
913
936
  0,
@@ -958,7 +981,9 @@ async function waiting(ctx, pack) {
958
981
  /** Every open pack whose agent wrote its answer, or whose saved answer is not recorded yet. */
959
982
  async function ingestAll(ctx) {
960
983
  const changes = await observe(ctx)
984
+ await reclaimLostPacks(ctx)
961
985
  const ingested = []
986
+ const freedSlots = new Set((ctx.lost ?? []).map((pack) => pack.packId))
962
987
  let stopped = false
963
988
  const terms = { added: 0, kept: 0, unlocated: 0 }
964
989
  for (const pack of await readAll(join(ctx.run, 'packs'))) {
@@ -981,6 +1006,13 @@ async function ingestAll(ctx) {
981
1006
  })
982
1007
  continue
983
1008
  }
1009
+ if (
1010
+ !pack.units.some(({ batchId }) => {
1011
+ const unit = ctx.units.get(batchId)
1012
+ return unit?.packId === pack.id && unit.state === 'dispatched'
1013
+ })
1014
+ )
1015
+ freedSlots.add(pack.id)
984
1016
  for (const key of Object.keys(terms)) terms[key] += result.terms[key]
985
1017
  ingested.push({
986
1018
  packId: pack.id,
@@ -1001,6 +1033,8 @@ async function ingestAll(ctx) {
1001
1033
  if (!paused(ctx) && !stopped && !overBudget(ctx)) await readNext(ctx)
1002
1034
  return {
1003
1035
  changes,
1036
+ lost: ctx.lost ?? [],
1037
+ freedSlots: [...freedSlots],
1004
1038
  ingested,
1005
1039
  terms,
1006
1040
  ...(await progressOf(ctx)),
@@ -1009,6 +1043,42 @@ async function ingestAll(ctx) {
1009
1043
  next: stopped ? 'ingest' : await suggest(ctx),
1010
1044
  }
1011
1045
  }
1046
+ async function scanTiming(ctx, cardAgents, ingestStartedAt) {
1047
+ const receipt = await readJson(join(ctx.run, 'scan-timing.json'), null)
1048
+ if (!receipt || receipt.completedAt) return
1049
+ const packs = []
1050
+ for (const pack of await readAll(join(ctx.run, 'packs'))) {
1051
+ let publishedAt = null
1052
+ try {
1053
+ publishedAt = (await stat(answerPath(pack.retryTask ?? pack.task))).mtimeMs
1054
+ } catch (error) {
1055
+ if (error.code !== 'ENOENT') throw error
1056
+ }
1057
+ const active =
1058
+ pack.state === 'open' &&
1059
+ pack.units.some(({ batchId }) => {
1060
+ const unit = ctx.units.get(batchId)
1061
+ return unit?.packId === pack.id && unit.state === 'dispatched'
1062
+ })
1063
+ if ((active || publishedAt !== null) && Number.isFinite(Date.parse(pack.dispatchedAt)))
1064
+ packs.push({ id: pack.id, dispatchedAt: pack.dispatchedAt, publishedAt })
1065
+ }
1066
+ await observeScanTiming(ctx.run, {
1067
+ at: Date.now(),
1068
+ ...(ingestStartedAt === undefined ? {} : { ingestStartedAt }),
1069
+ packs,
1070
+ activeCardChildren: cardAgents ?? receipt.measurements?.last?.activeCardChildren ?? 0,
1071
+ queuedBatches: ctx.state.queue.length,
1072
+ unreadBatches: ctx.state.reader?.remaining ?? 0,
1073
+ waiting: ctx.state.reader?.waiting,
1074
+ pendingWorkIds: ctx.view.works
1075
+ .filter(
1076
+ (work) =>
1077
+ work.access !== 'drop' && work.rootSessionIds.length > 0 && work.status === 'pending',
1078
+ )
1079
+ .map((work) => work.id),
1080
+ })
1081
+ }
1012
1082
  /** Refill only confirmed free native slots, after exact ingestion has settled. */
1013
1083
  async function ingestAndRefill(ctx, options) {
1014
1084
  const auto = options.agents === 'auto'
@@ -1017,16 +1087,18 @@ async function ingestAndRefill(ctx, options) {
1017
1087
  fail('INVALID_ARGUMENTS')
1018
1088
  const result = await ingestAll(ctx)
1019
1089
  const outstanding = await outstandingPacks(ctx)
1020
- const agents = auto ? Math.max(0, SLOTS - outstanding.length) : requested
1021
- const now = Date.now()
1022
- const stale = outstanding
1023
- .filter((pack) => now - Date.parse(pack.retryAt ?? pack.dispatchedAt) > STALE_MS)
1024
- .map((pack) => ({
1025
- packId: pack.id,
1026
- ageMs: now - Date.parse(pack.retryAt ?? pack.dispatchedAt),
1027
- prompt: pack.retryPrompt ?? pack.prompt,
1028
- }))
1029
- const slots = { ...(auto ? { freeSlots: agents } : {}), ...(stale.length ? { stale } : {}) }
1090
+ const lostSlots = result.lost.length
1091
+ const available = Math.max(
1092
+ 0,
1093
+ SLOTS - outstanding.length - Number(options['card-agents'] ?? 0) - lostSlots,
1094
+ )
1095
+ const plannedAgents = result.cardBatch.ready
1096
+ ? Math.min(available, Number(options['planned-card-agents'] ?? 1))
1097
+ : 0
1098
+ result.cardBatch.plannedAgents = plannedAgents
1099
+ // An explicit count never takes live leases past the sixteen slots (ADR 0179).
1100
+ const agents = auto ? available - plannedAgents : Math.min(requested, available - plannedAgents)
1101
+ const slots = auto ? { freeSlots: agents } : {}
1030
1102
  const unsettled = result.ingested.some(
1031
1103
  (item) =>
1032
1104
  item.error ||
@@ -1082,19 +1154,22 @@ async function autoRefill(ctx, options) {
1082
1154
  // ADR 0234: a card batch can come due on time alone; neither gathering slots nor waiting for a
1083
1155
  // child holds it back past that time.
1084
1156
  await progressOf(ctx)
1085
- const wake = () => Math.min(until, ctx.cardsDueAt ?? until)
1157
+ // A batch already due wakes nothing more: it waits for a free slot like any refill.
1158
+ const wake = () => (ctx.cardsDueAt > Date.now() ? Math.min(until, ctx.cardsDueAt) : until)
1086
1159
  // A slow pass can outlast the wait while a child publishes; ingest that answer once more
1087
1160
  // rather than returning idle and leaving its slot unused until the next call.
1088
1161
  let lastPass = false
1089
1162
  for (;;) {
1090
- if (!lastPass) await gatherSlots(ctx, wake())
1163
+ if (!lastPass) await gatherSlots(ctx, wake(), Number(options['card-agents'] ?? 0))
1091
1164
  const result = await ingestAndRefill(ctx, options)
1092
1165
  const idle =
1093
1166
  !result.ingested.length &&
1167
+ !result.lost.length &&
1094
1168
  !result.packs.length &&
1095
1169
  !result.paused &&
1096
1170
  !result.continue &&
1097
- !result.cardBatch.ready &&
1171
+ // A due batch with no slot to plan a child on keeps waiting instead of spinning.
1172
+ !(result.cardBatch.ready && result.cardBatch.plannedAgents > 0) &&
1098
1173
  (result.next === 'wait-for-children' || result.next.startsWith('next-packs'))
1099
1174
  if (!idle || lastPass) return result
1100
1175
  if (Date.now() >= until) {
@@ -1109,19 +1184,83 @@ async function autoRefill(ctx, options) {
1109
1184
  )
1110
1185
  }
1111
1186
  }
1112
- /** Wait, within `until`, for GATHER_SLOTS free slots or GATHER_MS after the first one frees. */
1113
- async function gatherSlots(ctx, until) {
1114
- let firstFree = null
1187
+ /** Return at the first free slot; card deadlines still bound the wait. */
1188
+ async function gatherSlots(ctx, until, cardAgents = 0) {
1115
1189
  while (Date.now() < until) {
1116
- const free = SLOTS - (await outstandingPacks(ctx)).length
1117
- if (free >= GATHER_SLOTS) return
1118
- if (free > 0) {
1119
- firstFree ??= Date.now()
1120
- if (Date.now() - firstFree >= GATHER_MS) return
1121
- }
1190
+ await reclaimLostPacks(ctx)
1191
+ const free = SLOTS - cardAgents - (await outstandingPacks(ctx)).length - (ctx.lost?.length ?? 0)
1192
+ if (ctx.lost?.length) return
1193
+ if (free > 0) return
1122
1194
  await new Promise((done) => setTimeout(done, Math.max(1, Math.min(1_000, until - Date.now()))))
1123
1195
  }
1124
1196
  }
1197
+ /** Fence lost task identities before releasing their batch reservations; resume after a crash. */
1198
+ async function reclaimLostPacks(ctx) {
1199
+ ctx.lost ??= []
1200
+ // Lease ages are wall-clock. After a gap no helper observed (a suspended machine, a parent that
1201
+ // stopped calling), every live lease restarts instead of all being lost at once.
1202
+ const now = Date.now()
1203
+ const gap = ctx.state.leaseCheckAt != null && now - ctx.state.leaseCheckAt > LOST_PACK_TIMEOUT_MS
1204
+ if (gap) ctx.state.leaseRearmedAt = now
1205
+ if (gap || ctx.state.leaseCheckAt == null || now - ctx.state.leaseCheckAt > 5_000) {
1206
+ ctx.state.leaseCheckAt = now
1207
+ await saveState(ctx)
1208
+ }
1209
+ for (const pack of await readAll(join(ctx.run, 'packs'))) {
1210
+ if (pack.state !== 'lost') {
1211
+ if (
1212
+ pack.state !== 'open' ||
1213
+ pack.repair ||
1214
+ !pack.units.some(({ batchId }) => {
1215
+ const unit = ctx.units.get(batchId)
1216
+ return unit?.packId === pack.id && unit.state === 'dispatched'
1217
+ }) ||
1218
+ (await waiting(ctx, pack))
1219
+ )
1220
+ continue
1221
+ const ageMs = now - Date.parse(pack.retryAt ?? pack.dispatchedAt)
1222
+ // Each earlier reclaim of one of its batches doubles the bound (at most 4x), so a slow
1223
+ // reader or a rate-limited account is not re-dispatched again and again.
1224
+ const reclaims = Math.max(0, ...pack.units.map((u) => ctx.state.reclaims?.[u.batchId] ?? 0))
1225
+ const bound = LOST_PACK_TIMEOUT_MS * 2 ** Math.min(reclaims, 2)
1226
+ const since = Math.min(ageMs, now - (ctx.state.leaseRearmedAt ?? 0))
1227
+ if (!(since >= bound)) continue
1228
+ // A retained original draft cannot keep a later retry child alive.
1229
+ const task = pack.retryTask ?? pack.task
1230
+ try {
1231
+ const draft = await stat(task.replace(/\.md$/u, '.draft.json'))
1232
+ if (now - draft.mtimeMs < DRAFT_HEARTBEAT_TIMEOUT_MS) continue
1233
+ } catch (error) {
1234
+ if (error.code !== 'ENOENT') throw error
1235
+ }
1236
+ pack.state = 'lost'
1237
+ pack.lostAt = new Date().toISOString()
1238
+ pack.lostAgeMs = ageMs
1239
+ await savePack(ctx, pack)
1240
+ }
1241
+ if (!pack.lostAcknowledgedAt) {
1242
+ if (!ctx.lost.some((item) => item.packId === pack.id))
1243
+ ctx.lost.push({ packId: pack.id, ageMs: pack.lostAgeMs })
1244
+ continue
1245
+ }
1246
+ for (const { batchId } of pack.units) {
1247
+ const unit = ctx.units.get(batchId)
1248
+ if (unit?.packId !== pack.id || unit.state !== 'dispatched') continue
1249
+ const batch = await loadBatch(ctx, batchId)
1250
+ if (!batch) fail('CORRUPTED_STATE')
1251
+ if (!ctx.state.queue.some((item) => item.batchId === batchId)) {
1252
+ ctx.state.queue.push(queueItem(batch))
1253
+ ctx.state.reclaims = {
1254
+ ...ctx.state.reclaims,
1255
+ [batchId]: (ctx.state.reclaims?.[batchId] ?? 0) + 1,
1256
+ }
1257
+ await saveState(ctx)
1258
+ }
1259
+ await rm(unitPath(ctx, batchId), { force: true })
1260
+ ctx.units.delete(batchId)
1261
+ }
1262
+ }
1263
+ }
1125
1264
  async function answerPublished(ctx) {
1126
1265
  for (const pack of await readAll(join(ctx.run, 'packs')))
1127
1266
  if (await waiting(ctx, pack)) return true
@@ -1451,8 +1590,51 @@ async function progressOf(ctx) {
1451
1590
  blockedWorks: blockedWorks(view),
1452
1591
  }
1453
1592
  }
1593
+ function pendingPreparation(view, blocked = []) {
1594
+ return view.works
1595
+ .filter(
1596
+ (work) =>
1597
+ work.access !== 'drop' &&
1598
+ work.rootSessionIds.length > 0 &&
1599
+ work.status === 'pending' &&
1600
+ (work.totalBatches === 0 || blocked.includes(`${work.id}:${work.inputRevision}`)),
1601
+ )
1602
+ .map((work) => `${work.id}:${work.inputRevision}:${work.revision}`)
1603
+ .sort()
1604
+ .join('|')
1605
+ }
1454
1606
  async function suggest(ctx, readOnly = false) {
1455
1607
  if (paused(ctx)) return 'paused'
1608
+ // Retry changes revision but not inputRevision. Check pending input before the reading queue
1609
+ // drains, once per transition (and once after the owned prepare completes).
1610
+ const pending = pendingPreparation(ctx.view, ctx.state.preparationBlocked)
1611
+ if (!readOnly && ctx.state.preparationBusyPending) {
1612
+ ctx.state.preparationPending = pending || null
1613
+ ctx.state.preparationBusyPending = false
1614
+ await saveState(ctx)
1615
+ }
1616
+ // A live owned prepare admits newly pending Works through its read drain; its exit resets
1617
+ // preparationPending, so anything it left pending is suggested then.
1618
+ const ownedPrepareLive = ctx.state.preparationExitGeneration !== ctx.state.preparationGeneration
1619
+ if (
1620
+ ctx.state.preparationEverStarted &&
1621
+ !ownedPrepareLive &&
1622
+ pending &&
1623
+ pending
1624
+ .split('|')
1625
+ .some((item) => !(ctx.state.preparationPending ?? '').split('|').includes(item)) &&
1626
+ !ctx.state.preparationFailed
1627
+ ) {
1628
+ if (!readOnly) {
1629
+ ctx.state.preparationPending = pending
1630
+ await saveState(ctx)
1631
+ }
1632
+ return 'journey.prepare'
1633
+ }
1634
+ if (!readOnly && pending !== (ctx.state.preparationPending ?? '')) {
1635
+ ctx.state.preparationPending = pending || null
1636
+ await saveState(ctx)
1637
+ }
1456
1638
  const packs = await readAll(join(ctx.run, 'packs'))
1457
1639
  for (const pack of packs) if (await waiting(ctx, pack)) return 'ingest'
1458
1640
  if (packs.some((pack) => pack.repair)) return 'repair-answer'
@@ -1553,14 +1735,33 @@ async function status(ctx) {
1553
1735
  async function prepareOutcome(ctx, options) {
1554
1736
  const { result, code } = options
1555
1737
  if (
1556
- !['busy', 'started', 'failed'].includes(result) ||
1738
+ !['busy', 'started', 'completed', 'failed'].includes(result) ||
1557
1739
  (result === 'busy' && code !== 'DISCOVERY_BUSY') ||
1558
- (result === 'started' && code !== undefined) ||
1740
+ (['started', 'completed'].includes(result) && code !== undefined) ||
1559
1741
  (result === 'failed' && (!/^[A-Z_]{3,80}$/u.test(code ?? '') || code === 'DISCOVERY_BUSY'))
1560
1742
  )
1561
1743
  fail('INVALID_ARGUMENTS')
1562
- ctx.state.preparationStarted = result === 'started'
1744
+ if (result !== 'completed') ctx.state.preparationStarted = result === 'started'
1745
+ if (result === 'started') {
1746
+ ctx.state.preparationPending = pendingPreparation(
1747
+ await call(ctx, 'journey.get', { journeyId: ctx.journeyId }),
1748
+ ctx.state.preparationBlocked,
1749
+ )
1750
+ ctx.state.preparationEverStarted = true
1751
+ ctx.state.preparationBusyPending = false
1752
+ ctx.state.preparationGeneration = (ctx.state.preparationGeneration ?? 0) + 1
1753
+ }
1754
+ if (
1755
+ result === 'completed' &&
1756
+ ctx.state.preparationEverStarted &&
1757
+ ctx.state.preparationExitGeneration !== ctx.state.preparationGeneration
1758
+ ) {
1759
+ ctx.state.preparationExitGeneration = ctx.state.preparationGeneration
1760
+ ctx.state.preparationBusyPending = false
1761
+ ctx.state.preparationPending = null
1762
+ }
1563
1763
  if (result === 'busy') {
1764
+ ctx.state.preparationBusyPending = true
1564
1765
  ctx.state.preparationRestarts = 0
1565
1766
  ctx.state.preparationFailed = false
1566
1767
  }
@@ -1639,6 +1840,9 @@ export async function main(argv) {
1639
1840
  run: { type: 'string' },
1640
1841
  journey: { type: 'string' },
1641
1842
  agents: { type: 'string' },
1843
+ 'card-agents': { type: 'string' },
1844
+ 'ack-lost': { type: 'string' },
1845
+ 'planned-card-agents': { type: 'string' },
1642
1846
  model: { type: 'string' },
1643
1847
  effort: { type: 'string' },
1644
1848
  input: { type: 'string' },
@@ -1656,6 +1860,23 @@ export async function main(argv) {
1656
1860
  const minimum = command === 'ingest' ? 0 : 1
1657
1861
  if (!Number.isInteger(agents) || agents < minimum || agents > SLOTS) fail('INVALID_ARGUMENTS')
1658
1862
  }
1863
+ const cardAgents = Number(values['card-agents'] ?? 0)
1864
+ if (
1865
+ !Number.isInteger(cardAgents) ||
1866
+ cardAgents < 0 ||
1867
+ cardAgents > SLOTS ||
1868
+ (values['card-agents'] !== undefined && !auto)
1869
+ )
1870
+ fail('INVALID_ARGUMENTS')
1871
+ const plannedCardAgents = Number(values['planned-card-agents'] ?? 1)
1872
+ if (
1873
+ !Number.isInteger(plannedCardAgents) ||
1874
+ plannedCardAgents < 1 ||
1875
+ plannedCardAgents > SLOTS ||
1876
+ (values['planned-card-agents'] !== undefined && !auto) ||
1877
+ (values['ack-lost'] !== undefined && (command !== 'ingest' || positionals[1]))
1878
+ )
1879
+ fail('INVALID_ARGUMENTS')
1659
1880
  const waitMs = values['wait-ms'] === undefined ? 0 : Number(values['wait-ms'])
1660
1881
  if (!(Number.isInteger(waitMs) && waitMs >= 0) || (values['wait-ms'] !== undefined && !auto))
1661
1882
  fail('INVALID_ARGUMENTS')
@@ -1680,9 +1901,44 @@ export async function main(argv) {
1680
1901
  fail('INVALID_ARGUMENTS')
1681
1902
  const budget = values['budget-ms'] === undefined ? undefined : Number(values['budget-ms'])
1682
1903
  if (budget !== undefined && !(Number.isInteger(budget) && budget >= 0)) fail('INVALID_ARGUMENTS')
1904
+ const started = Date.now()
1683
1905
  const ctx = await openRun(values.run, budget)
1684
1906
  try {
1685
- return { ...(await handler(ctx)), agentProfile: ctx.config.agentProfile }
1907
+ if (values['ack-lost'] !== undefined) {
1908
+ const ids = values['ack-lost'].split(',')
1909
+ if (!ids.length || ids.some((id) => !/^[a-zA-Z0-9-]+$/u.test(id))) fail('INVALID_ARGUMENTS')
1910
+ const packs = await readAll(join(ctx.run, 'packs'))
1911
+ const acknowledged = ids.map((id) => packs.find((pack) => pack.id === id))
1912
+ if (acknowledged.some((pack) => !pack || pack.state !== 'lost')) fail('UNKNOWN_PACK')
1913
+ for (const pack of acknowledged) {
1914
+ pack.lostAcknowledgedAt ??= new Date().toISOString()
1915
+ await savePack(ctx, pack)
1916
+ }
1917
+ }
1918
+ const result = await handler(ctx)
1919
+ if (['ingest', 'next-packs'].includes(command) && ctx.view) {
1920
+ try {
1921
+ await scanTiming(
1922
+ ctx,
1923
+ command === 'ingest' ? cardAgents : undefined,
1924
+ command === 'ingest' ? started : undefined,
1925
+ )
1926
+ } catch {
1927
+ // Observability must never turn a committed ingest into a retryable failure.
1928
+ console.error('SCAN_TIMING_FAILED')
1929
+ try {
1930
+ await timing(ctx, {
1931
+ phase: 'local',
1932
+ operation: 'scanTiming',
1933
+ started: new Date(started),
1934
+ outcome: 'SCAN_TIMING_FAILED',
1935
+ })
1936
+ } catch {
1937
+ console.error('SCAN_TIMING_DIAGNOSTIC_FAILED')
1938
+ }
1939
+ }
1940
+ }
1941
+ return { ...result, agentProfile: ctx.config.agentProfile }
1686
1942
  } finally {
1687
1943
  await Promise.all((ctx.streams ?? []).map((item) => item.close()))
1688
1944
  await ctx.release()
package/LICENSE DELETED
@@ -1,11 +0,0 @@
1
- PROPRIETARY SOFTWARE — ALL RIGHTS RESERVED
2
-
3
- This repository is not licensed as open-source software. Except for permissions
4
- expressly granted by the applicable rights holders in a separate written agreement,
5
- no permission is granted to use, copy, modify, distribute, sublicense, or sell this
6
- software or substantial portions of it. Public access to source code does not itself
7
- grant such permission. Rights provided by applicable law or the hosting platform's
8
- terms are not limited by this notice.
9
-
10
- Third-party components remain subject to their respective licenses and notices.
11
- This notice does not replace or restrict permissions granted by those licenses.