@gitroll/tacitt-cli 0.6.7 → 0.6.9

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 (32) hide show
  1. package/README.md +1 -1
  2. package/dist/{agent-setup-YlFIJz-h.mjs → agent-setup-CODlN6zl.mjs} +1 -1
  3. package/dist/agent-setup-worker.mjs +4 -4
  4. package/dist/cli.mjs +5 -5
  5. package/dist/codex-title-worker.mjs +1 -1
  6. package/dist/configured-preview-worker.mjs +4 -4
  7. package/dist/configured-session-worker.mjs +4 -4
  8. package/dist/{heap-DsE74_-n.mjs → heap-Dt3-d27v.mjs} +2 -2
  9. package/dist/inspection-worker.mjs +3 -3
  10. package/dist/{job-CeSf9UCC.mjs → job-BQXssW46.mjs} +3 -3
  11. package/dist/{model-f_QnDyyb.mjs → model-CSx2dP_P.mjs} +1 -1
  12. package/dist/operation-executor.mjs +2 -2
  13. package/dist/preview-worker.mjs +1 -1
  14. package/dist/{query-Bb72w2DX.mjs → query-CfhcS4gd.mjs} +194 -134
  15. package/dist/{run-RA_8zZsL.mjs → run-xlOaKCk8.mjs} +2 -2
  16. package/dist/{src-BBjnljUs.mjs → src-BgsEnMsy.mjs} +3 -3
  17. package/dist/{src-d7n58sbH.mjs → src-DzKuzGVH.mjs} +183 -147
  18. package/dist/{src-p_uJNjV3.mjs → src-V9GHLZQs.mjs} +1 -1
  19. package/dist/store-database-worker.mjs +1 -1
  20. package/dist/web/assets/{index-B6kJ6XXF.js → index-9UfCT_2C.js} +1 -1
  21. package/dist/web/index.html +1 -1
  22. package/package.json +1 -1
  23. package/skills/tacitt/SKILL.md +3 -3
  24. package/skills/tacitt/references/commands.md +50 -11
  25. package/skills/tacitt/references/journey.md +1 -1
  26. package/skills/tacitt/references/parallel-review.md +21 -0
  27. package/skills/tacitt/scripts/INDEX.md +39 -36
  28. package/skills/tacitt/scripts/journey-scan-timing.d.mts +15 -0
  29. package/skills/tacitt/scripts/journey-scan-timing.mjs +153 -1
  30. package/skills/tacitt/scripts/journey.mjs +15 -0
  31. package/skills/tacitt/scripts/review.mjs +280 -36
  32. package/LICENSE +0 -11
@@ -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
@@ -301,7 +300,17 @@ async function observe(ctx, readOnly = false) {
301
300
  ctx.state.preparationFailed = false
302
301
  if (ctx.state.reader) ctx.state.reader.waitingSince = null
303
302
  }
304
- if (!readOnly) ctx.state.completedWorks = completedWorks
303
+ if (!readOnly) {
304
+ ctx.state.completedWorks = completedWorks
305
+ ctx.state.preparationBlocked = [
306
+ ...new Set([
307
+ ...(ctx.state.preparationBlocked ?? []),
308
+ ...view.works
309
+ .filter((work) => work.status === 'blocked')
310
+ .map((work) => `${work.id}:${work.inputRevision}`),
311
+ ]),
312
+ ]
313
+ }
305
314
  const works = Object.fromEntries(
306
315
  view.works.map((work) => [work.id, { inputRevision: work.inputRevision, access: work.access }]),
307
316
  )
@@ -587,7 +596,7 @@ const withAnswer = (task, text) => {
587
596
  // Each pack names its own draft so concurrent children never share a scratch file.
588
597
  const draft = resolve(task.replace(/\.md$/u, '.draft.json'))
589
598
  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`
599
+ 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
600
  }
592
601
  /** Validate a complete task answer before the publisher replaces any prior answer. */
593
602
  export function validatePrivacyAnswer(answer, unitRefs) {
@@ -656,7 +665,7 @@ function packPrompt(task, text) {
656
665
  const reads = plan.map(({ offset, limit }) => `(${offset},${limit})`).join(' ')
657
666
  // In the prompt itself, not a start file: given the plan indirectly, fast models read the
658
667
  // 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.`
668
+ 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
669
  }
661
670
  /** Writes a task and its publisher and returns the agent's prompt. */
662
671
  export async function writePackTask(task, text, unitRefs, sourceBatches = []) {
@@ -908,6 +917,8 @@ async function finishPack(ctx, meta) {
908
917
  ctx.state.queue.unshift(...requeue)
909
918
  meta.units = kept
910
919
  meta.state = kept.length ? 'open' : 'empty'
920
+ // The lease starts when the task is handed out, also for a pack resumed from dispatching.
921
+ meta.dispatchedAt = new Date().toISOString()
911
922
  meta.codePoints = batches.reduce(
912
923
  (total, item) => total + item.unit.coreCodePoints + item.unit.contextCodePoints,
913
924
  0,
@@ -958,7 +969,9 @@ async function waiting(ctx, pack) {
958
969
  /** Every open pack whose agent wrote its answer, or whose saved answer is not recorded yet. */
959
970
  async function ingestAll(ctx) {
960
971
  const changes = await observe(ctx)
972
+ await reclaimLostPacks(ctx)
961
973
  const ingested = []
974
+ const freedSlots = new Set((ctx.lost ?? []).map((pack) => pack.packId))
962
975
  let stopped = false
963
976
  const terms = { added: 0, kept: 0, unlocated: 0 }
964
977
  for (const pack of await readAll(join(ctx.run, 'packs'))) {
@@ -981,6 +994,13 @@ async function ingestAll(ctx) {
981
994
  })
982
995
  continue
983
996
  }
997
+ if (
998
+ !pack.units.some(({ batchId }) => {
999
+ const unit = ctx.units.get(batchId)
1000
+ return unit?.packId === pack.id && unit.state === 'dispatched'
1001
+ })
1002
+ )
1003
+ freedSlots.add(pack.id)
984
1004
  for (const key of Object.keys(terms)) terms[key] += result.terms[key]
985
1005
  ingested.push({
986
1006
  packId: pack.id,
@@ -1001,6 +1021,8 @@ async function ingestAll(ctx) {
1001
1021
  if (!paused(ctx) && !stopped && !overBudget(ctx)) await readNext(ctx)
1002
1022
  return {
1003
1023
  changes,
1024
+ lost: ctx.lost ?? [],
1025
+ freedSlots: [...freedSlots],
1004
1026
  ingested,
1005
1027
  terms,
1006
1028
  ...(await progressOf(ctx)),
@@ -1009,6 +1031,42 @@ async function ingestAll(ctx) {
1009
1031
  next: stopped ? 'ingest' : await suggest(ctx),
1010
1032
  }
1011
1033
  }
1034
+ async function scanTiming(ctx, cardAgents, ingestStartedAt) {
1035
+ const receipt = await readJson(join(ctx.run, 'scan-timing.json'), null)
1036
+ if (!receipt || receipt.completedAt) return
1037
+ const packs = []
1038
+ for (const pack of await readAll(join(ctx.run, 'packs'))) {
1039
+ let publishedAt = null
1040
+ try {
1041
+ publishedAt = (await stat(answerPath(pack.retryTask ?? pack.task))).mtimeMs
1042
+ } catch (error) {
1043
+ if (error.code !== 'ENOENT') throw error
1044
+ }
1045
+ const active =
1046
+ pack.state === 'open' &&
1047
+ pack.units.some(({ batchId }) => {
1048
+ const unit = ctx.units.get(batchId)
1049
+ return unit?.packId === pack.id && unit.state === 'dispatched'
1050
+ })
1051
+ if ((active || publishedAt !== null) && Number.isFinite(Date.parse(pack.dispatchedAt)))
1052
+ packs.push({ id: pack.id, dispatchedAt: pack.dispatchedAt, publishedAt })
1053
+ }
1054
+ await observeScanTiming(ctx.run, {
1055
+ at: Date.now(),
1056
+ ...(ingestStartedAt === undefined ? {} : { ingestStartedAt }),
1057
+ packs,
1058
+ activeCardChildren: cardAgents ?? receipt.measurements?.last?.activeCardChildren ?? 0,
1059
+ queuedBatches: ctx.state.queue.length,
1060
+ unreadBatches: ctx.state.reader?.remaining ?? 0,
1061
+ waiting: ctx.state.reader?.waiting,
1062
+ pendingWorkIds: ctx.view.works
1063
+ .filter(
1064
+ (work) =>
1065
+ work.access !== 'drop' && work.rootSessionIds.length > 0 && work.status === 'pending',
1066
+ )
1067
+ .map((work) => work.id),
1068
+ })
1069
+ }
1012
1070
  /** Refill only confirmed free native slots, after exact ingestion has settled. */
1013
1071
  async function ingestAndRefill(ctx, options) {
1014
1072
  const auto = options.agents === 'auto'
@@ -1017,16 +1075,18 @@ async function ingestAndRefill(ctx, options) {
1017
1075
  fail('INVALID_ARGUMENTS')
1018
1076
  const result = await ingestAll(ctx)
1019
1077
  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 } : {}) }
1078
+ const lostSlots = result.lost.length
1079
+ const available = Math.max(
1080
+ 0,
1081
+ SLOTS - outstanding.length - Number(options['card-agents'] ?? 0) - lostSlots,
1082
+ )
1083
+ const plannedAgents = result.cardBatch.ready
1084
+ ? Math.min(available, Number(options['planned-card-agents'] ?? 1))
1085
+ : 0
1086
+ result.cardBatch.plannedAgents = plannedAgents
1087
+ // An explicit count never takes live leases past the sixteen slots (ADR 0179).
1088
+ const agents = auto ? available - plannedAgents : Math.min(requested, available - plannedAgents)
1089
+ const slots = auto ? { freeSlots: agents } : {}
1030
1090
  const unsettled = result.ingested.some(
1031
1091
  (item) =>
1032
1092
  item.error ||
@@ -1082,19 +1142,22 @@ async function autoRefill(ctx, options) {
1082
1142
  // ADR 0234: a card batch can come due on time alone; neither gathering slots nor waiting for a
1083
1143
  // child holds it back past that time.
1084
1144
  await progressOf(ctx)
1085
- const wake = () => Math.min(until, ctx.cardsDueAt ?? until)
1145
+ // A batch already due wakes nothing more: it waits for a free slot like any refill.
1146
+ const wake = () => (ctx.cardsDueAt > Date.now() ? Math.min(until, ctx.cardsDueAt) : until)
1086
1147
  // A slow pass can outlast the wait while a child publishes; ingest that answer once more
1087
1148
  // rather than returning idle and leaving its slot unused until the next call.
1088
1149
  let lastPass = false
1089
1150
  for (;;) {
1090
- if (!lastPass) await gatherSlots(ctx, wake())
1151
+ if (!lastPass) await gatherSlots(ctx, wake(), Number(options['card-agents'] ?? 0))
1091
1152
  const result = await ingestAndRefill(ctx, options)
1092
1153
  const idle =
1093
1154
  !result.ingested.length &&
1155
+ !result.lost.length &&
1094
1156
  !result.packs.length &&
1095
1157
  !result.paused &&
1096
1158
  !result.continue &&
1097
- !result.cardBatch.ready &&
1159
+ // A due batch with no slot to plan a child on keeps waiting instead of spinning.
1160
+ !(result.cardBatch.ready && result.cardBatch.plannedAgents > 0) &&
1098
1161
  (result.next === 'wait-for-children' || result.next.startsWith('next-packs'))
1099
1162
  if (!idle || lastPass) return result
1100
1163
  if (Date.now() >= until) {
@@ -1109,19 +1172,83 @@ async function autoRefill(ctx, options) {
1109
1172
  )
1110
1173
  }
1111
1174
  }
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
1175
+ /** Return at the first free slot; card deadlines still bound the wait. */
1176
+ async function gatherSlots(ctx, until, cardAgents = 0) {
1115
1177
  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
- }
1178
+ await reclaimLostPacks(ctx)
1179
+ const free = SLOTS - cardAgents - (await outstandingPacks(ctx)).length - (ctx.lost?.length ?? 0)
1180
+ if (ctx.lost?.length) return
1181
+ if (free > 0) return
1122
1182
  await new Promise((done) => setTimeout(done, Math.max(1, Math.min(1_000, until - Date.now()))))
1123
1183
  }
1124
1184
  }
1185
+ /** Fence lost task identities before releasing their batch reservations; resume after a crash. */
1186
+ async function reclaimLostPacks(ctx) {
1187
+ ctx.lost ??= []
1188
+ // Lease ages are wall-clock. After a gap no helper observed (a suspended machine, a parent that
1189
+ // stopped calling), every live lease restarts instead of all being lost at once.
1190
+ const now = Date.now()
1191
+ const gap = ctx.state.leaseCheckAt != null && now - ctx.state.leaseCheckAt > LOST_PACK_TIMEOUT_MS
1192
+ if (gap) ctx.state.leaseRearmedAt = now
1193
+ if (gap || ctx.state.leaseCheckAt == null || now - ctx.state.leaseCheckAt > 5_000) {
1194
+ ctx.state.leaseCheckAt = now
1195
+ await saveState(ctx)
1196
+ }
1197
+ for (const pack of await readAll(join(ctx.run, 'packs'))) {
1198
+ if (pack.state !== 'lost') {
1199
+ if (
1200
+ pack.state !== 'open' ||
1201
+ pack.repair ||
1202
+ !pack.units.some(({ batchId }) => {
1203
+ const unit = ctx.units.get(batchId)
1204
+ return unit?.packId === pack.id && unit.state === 'dispatched'
1205
+ }) ||
1206
+ (await waiting(ctx, pack))
1207
+ )
1208
+ continue
1209
+ const ageMs = now - Date.parse(pack.retryAt ?? pack.dispatchedAt)
1210
+ // Each earlier reclaim of one of its batches doubles the bound (at most 4x), so a slow
1211
+ // reader or a rate-limited account is not re-dispatched again and again.
1212
+ const reclaims = Math.max(0, ...pack.units.map((u) => ctx.state.reclaims?.[u.batchId] ?? 0))
1213
+ const bound = LOST_PACK_TIMEOUT_MS * 2 ** Math.min(reclaims, 2)
1214
+ const since = Math.min(ageMs, now - (ctx.state.leaseRearmedAt ?? 0))
1215
+ if (!(since >= bound)) continue
1216
+ // A retained original draft cannot keep a later retry child alive.
1217
+ const task = pack.retryTask ?? pack.task
1218
+ try {
1219
+ const draft = await stat(task.replace(/\.md$/u, '.draft.json'))
1220
+ if (now - draft.mtimeMs < DRAFT_HEARTBEAT_TIMEOUT_MS) continue
1221
+ } catch (error) {
1222
+ if (error.code !== 'ENOENT') throw error
1223
+ }
1224
+ pack.state = 'lost'
1225
+ pack.lostAt = new Date().toISOString()
1226
+ pack.lostAgeMs = ageMs
1227
+ await savePack(ctx, pack)
1228
+ }
1229
+ if (!pack.lostAcknowledgedAt) {
1230
+ if (!ctx.lost.some((item) => item.packId === pack.id))
1231
+ ctx.lost.push({ packId: pack.id, ageMs: pack.lostAgeMs })
1232
+ continue
1233
+ }
1234
+ for (const { batchId } of pack.units) {
1235
+ const unit = ctx.units.get(batchId)
1236
+ if (unit?.packId !== pack.id || unit.state !== 'dispatched') continue
1237
+ const batch = await loadBatch(ctx, batchId)
1238
+ if (!batch) fail('CORRUPTED_STATE')
1239
+ if (!ctx.state.queue.some((item) => item.batchId === batchId)) {
1240
+ ctx.state.queue.push(queueItem(batch))
1241
+ ctx.state.reclaims = {
1242
+ ...ctx.state.reclaims,
1243
+ [batchId]: (ctx.state.reclaims?.[batchId] ?? 0) + 1,
1244
+ }
1245
+ await saveState(ctx)
1246
+ }
1247
+ await rm(unitPath(ctx, batchId), { force: true })
1248
+ ctx.units.delete(batchId)
1249
+ }
1250
+ }
1251
+ }
1125
1252
  async function answerPublished(ctx) {
1126
1253
  for (const pack of await readAll(join(ctx.run, 'packs')))
1127
1254
  if (await waiting(ctx, pack)) return true
@@ -1451,8 +1578,51 @@ async function progressOf(ctx) {
1451
1578
  blockedWorks: blockedWorks(view),
1452
1579
  }
1453
1580
  }
1581
+ function pendingPreparation(view, blocked = []) {
1582
+ return view.works
1583
+ .filter(
1584
+ (work) =>
1585
+ work.access !== 'drop' &&
1586
+ work.rootSessionIds.length > 0 &&
1587
+ work.status === 'pending' &&
1588
+ (work.totalBatches === 0 || blocked.includes(`${work.id}:${work.inputRevision}`)),
1589
+ )
1590
+ .map((work) => `${work.id}:${work.inputRevision}:${work.revision}`)
1591
+ .sort()
1592
+ .join('|')
1593
+ }
1454
1594
  async function suggest(ctx, readOnly = false) {
1455
1595
  if (paused(ctx)) return 'paused'
1596
+ // Retry changes revision but not inputRevision. Check pending input before the reading queue
1597
+ // drains, once per transition (and once after the owned prepare completes).
1598
+ const pending = pendingPreparation(ctx.view, ctx.state.preparationBlocked)
1599
+ if (!readOnly && ctx.state.preparationBusyPending) {
1600
+ ctx.state.preparationPending = pending || null
1601
+ ctx.state.preparationBusyPending = false
1602
+ await saveState(ctx)
1603
+ }
1604
+ // A live owned prepare admits newly pending Works through its read drain; its exit resets
1605
+ // preparationPending, so anything it left pending is suggested then.
1606
+ const ownedPrepareLive = ctx.state.preparationExitGeneration !== ctx.state.preparationGeneration
1607
+ if (
1608
+ ctx.state.preparationEverStarted &&
1609
+ !ownedPrepareLive &&
1610
+ pending &&
1611
+ pending
1612
+ .split('|')
1613
+ .some((item) => !(ctx.state.preparationPending ?? '').split('|').includes(item)) &&
1614
+ !ctx.state.preparationFailed
1615
+ ) {
1616
+ if (!readOnly) {
1617
+ ctx.state.preparationPending = pending
1618
+ await saveState(ctx)
1619
+ }
1620
+ return 'journey.prepare'
1621
+ }
1622
+ if (!readOnly && pending !== (ctx.state.preparationPending ?? '')) {
1623
+ ctx.state.preparationPending = pending || null
1624
+ await saveState(ctx)
1625
+ }
1456
1626
  const packs = await readAll(join(ctx.run, 'packs'))
1457
1627
  for (const pack of packs) if (await waiting(ctx, pack)) return 'ingest'
1458
1628
  if (packs.some((pack) => pack.repair)) return 'repair-answer'
@@ -1553,14 +1723,33 @@ async function status(ctx) {
1553
1723
  async function prepareOutcome(ctx, options) {
1554
1724
  const { result, code } = options
1555
1725
  if (
1556
- !['busy', 'started', 'failed'].includes(result) ||
1726
+ !['busy', 'started', 'completed', 'failed'].includes(result) ||
1557
1727
  (result === 'busy' && code !== 'DISCOVERY_BUSY') ||
1558
- (result === 'started' && code !== undefined) ||
1728
+ (['started', 'completed'].includes(result) && code !== undefined) ||
1559
1729
  (result === 'failed' && (!/^[A-Z_]{3,80}$/u.test(code ?? '') || code === 'DISCOVERY_BUSY'))
1560
1730
  )
1561
1731
  fail('INVALID_ARGUMENTS')
1562
- ctx.state.preparationStarted = result === 'started'
1732
+ if (result !== 'completed') ctx.state.preparationStarted = result === 'started'
1733
+ if (result === 'started') {
1734
+ ctx.state.preparationPending = pendingPreparation(
1735
+ await call(ctx, 'journey.get', { journeyId: ctx.journeyId }),
1736
+ ctx.state.preparationBlocked,
1737
+ )
1738
+ ctx.state.preparationEverStarted = true
1739
+ ctx.state.preparationBusyPending = false
1740
+ ctx.state.preparationGeneration = (ctx.state.preparationGeneration ?? 0) + 1
1741
+ }
1742
+ if (
1743
+ result === 'completed' &&
1744
+ ctx.state.preparationEverStarted &&
1745
+ ctx.state.preparationExitGeneration !== ctx.state.preparationGeneration
1746
+ ) {
1747
+ ctx.state.preparationExitGeneration = ctx.state.preparationGeneration
1748
+ ctx.state.preparationBusyPending = false
1749
+ ctx.state.preparationPending = null
1750
+ }
1563
1751
  if (result === 'busy') {
1752
+ ctx.state.preparationBusyPending = true
1564
1753
  ctx.state.preparationRestarts = 0
1565
1754
  ctx.state.preparationFailed = false
1566
1755
  }
@@ -1639,6 +1828,9 @@ export async function main(argv) {
1639
1828
  run: { type: 'string' },
1640
1829
  journey: { type: 'string' },
1641
1830
  agents: { type: 'string' },
1831
+ 'card-agents': { type: 'string' },
1832
+ 'ack-lost': { type: 'string' },
1833
+ 'planned-card-agents': { type: 'string' },
1642
1834
  model: { type: 'string' },
1643
1835
  effort: { type: 'string' },
1644
1836
  input: { type: 'string' },
@@ -1656,6 +1848,23 @@ export async function main(argv) {
1656
1848
  const minimum = command === 'ingest' ? 0 : 1
1657
1849
  if (!Number.isInteger(agents) || agents < minimum || agents > SLOTS) fail('INVALID_ARGUMENTS')
1658
1850
  }
1851
+ const cardAgents = Number(values['card-agents'] ?? 0)
1852
+ if (
1853
+ !Number.isInteger(cardAgents) ||
1854
+ cardAgents < 0 ||
1855
+ cardAgents > SLOTS ||
1856
+ (values['card-agents'] !== undefined && !auto)
1857
+ )
1858
+ fail('INVALID_ARGUMENTS')
1859
+ const plannedCardAgents = Number(values['planned-card-agents'] ?? 1)
1860
+ if (
1861
+ !Number.isInteger(plannedCardAgents) ||
1862
+ plannedCardAgents < 1 ||
1863
+ plannedCardAgents > SLOTS ||
1864
+ (values['planned-card-agents'] !== undefined && !auto) ||
1865
+ (values['ack-lost'] !== undefined && (command !== 'ingest' || positionals[1]))
1866
+ )
1867
+ fail('INVALID_ARGUMENTS')
1659
1868
  const waitMs = values['wait-ms'] === undefined ? 0 : Number(values['wait-ms'])
1660
1869
  if (!(Number.isInteger(waitMs) && waitMs >= 0) || (values['wait-ms'] !== undefined && !auto))
1661
1870
  fail('INVALID_ARGUMENTS')
@@ -1680,9 +1889,44 @@ export async function main(argv) {
1680
1889
  fail('INVALID_ARGUMENTS')
1681
1890
  const budget = values['budget-ms'] === undefined ? undefined : Number(values['budget-ms'])
1682
1891
  if (budget !== undefined && !(Number.isInteger(budget) && budget >= 0)) fail('INVALID_ARGUMENTS')
1892
+ const started = Date.now()
1683
1893
  const ctx = await openRun(values.run, budget)
1684
1894
  try {
1685
- return { ...(await handler(ctx)), agentProfile: ctx.config.agentProfile }
1895
+ if (values['ack-lost'] !== undefined) {
1896
+ const ids = values['ack-lost'].split(',')
1897
+ if (!ids.length || ids.some((id) => !/^[a-zA-Z0-9-]+$/u.test(id))) fail('INVALID_ARGUMENTS')
1898
+ const packs = await readAll(join(ctx.run, 'packs'))
1899
+ const acknowledged = ids.map((id) => packs.find((pack) => pack.id === id))
1900
+ if (acknowledged.some((pack) => !pack || pack.state !== 'lost')) fail('UNKNOWN_PACK')
1901
+ for (const pack of acknowledged) {
1902
+ pack.lostAcknowledgedAt ??= new Date().toISOString()
1903
+ await savePack(ctx, pack)
1904
+ }
1905
+ }
1906
+ const result = await handler(ctx)
1907
+ if (['ingest', 'next-packs'].includes(command) && ctx.view) {
1908
+ try {
1909
+ await scanTiming(
1910
+ ctx,
1911
+ command === 'ingest' ? cardAgents : undefined,
1912
+ command === 'ingest' ? started : undefined,
1913
+ )
1914
+ } catch {
1915
+ // Observability must never turn a committed ingest into a retryable failure.
1916
+ console.error('SCAN_TIMING_FAILED')
1917
+ try {
1918
+ await timing(ctx, {
1919
+ phase: 'local',
1920
+ operation: 'scanTiming',
1921
+ started: new Date(started),
1922
+ outcome: 'SCAN_TIMING_FAILED',
1923
+ })
1924
+ } catch {
1925
+ console.error('SCAN_TIMING_DIAGNOSTIC_FAILED')
1926
+ }
1927
+ }
1928
+ }
1929
+ return { ...result, agentProfile: ctx.config.agentProfile }
1686
1930
  } finally {
1687
1931
  await Promise.all((ctx.streams ?? []).map((item) => item.close()))
1688
1932
  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.