@commonlyai/cli 0.1.170 → 0.1.172
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 +1 -1
- package/src/commands/agent.js +222 -21
- package/src/lib/adapters/claude.js +21 -4
- package/src/lib/adapters/codex.js +7 -2
- package/src/lib/memory-bridge.js +26 -2
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -487,6 +487,28 @@ const inboxPriority = (event) => {
|
|
|
487
487
|
// event that represents the shared pod inbox is batched below.
|
|
488
488
|
const PRIVATE_RESPONSE_EVENT_TYPES = new Set(['agent.ask', 'agent.ask.response']);
|
|
489
489
|
|
|
490
|
+
// How often a live delivery lease is renewed: during a turn, and while a failed
|
|
491
|
+
// turn's events wait for their retry. The server requeues a delivery whose
|
|
492
|
+
// lease is 10+ minutes old (AGENT_EVENT_REQUEUE_DELIVERED_MINUTES).
|
|
493
|
+
const DELIVERY_RENEW_INTERVAL_MS = 60_000;
|
|
494
|
+
|
|
495
|
+
// A failed turn's events are carried in memory and re-run when the retry timer
|
|
496
|
+
// fires (see `holdForRetry` in performRun). Each local re-run is a delivery
|
|
497
|
+
// attempt the server's requeue cap (AGENT_EVENT_REQUEUE_MAX_ATTEMPTS, 3) never
|
|
498
|
+
// counts, so a carry with no bound would retry poison content forever and
|
|
499
|
+
// block every later event behind it. At this many consecutive failures of the
|
|
500
|
+
// same event the carry lets it go: renewal stops, and the server's requeue
|
|
501
|
+
// sweep redelivers it under the cap it already enforces. Three matches the
|
|
502
|
+
// point where spawnRetryPolicy stops treating an unknown failure as transient.
|
|
503
|
+
const HELD_RETRY_MAX_FAILURES = 3;
|
|
504
|
+
|
|
505
|
+
const deliveryIdOf = (event) => {
|
|
506
|
+
const deliveryId = event?.payload?.deliveryId;
|
|
507
|
+
return typeof deliveryId === 'string' && deliveryId ? deliveryId : null;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const countEvents = (count) => `${count} event${count === 1 ? '' : 's'}`;
|
|
511
|
+
|
|
490
512
|
// ── default environment for adapters that benefit from auto-MCP wiring ─────
|
|
491
513
|
|
|
492
514
|
// Which adapters can consume `mcp[]`, and the server declaration they get by
|
|
@@ -2301,6 +2323,145 @@ export const performRun = ({
|
|
|
2301
2323
|
let heldGreeting = [];
|
|
2302
2324
|
let greetingHoldSpent = false;
|
|
2303
2325
|
|
|
2326
|
+
// The same is true of a batch whose turn FAILED: its events are claimed, so
|
|
2327
|
+
// scheduling a later poll retries nothing — the next poll cannot return
|
|
2328
|
+
// them, and the old "retry scheduled" was really a 10–20 minute wait for the
|
|
2329
|
+
// requeue sweep with the process idle in between (sprint-review,
|
|
2330
|
+
// 2026-10-25). So a failed turn's events are carried exactly like the
|
|
2331
|
+
// greeting and rejoin the inbox when the retry timer fires. Their leases are
|
|
2332
|
+
// renewed while they wait, because an ack under a lapsed lease is a 409
|
|
2333
|
+
// `stale_delivery`, and that stops the run. The rule (CTO ruling,
|
|
2334
|
+
// 2026-10-25): a scheduled retry either fires, re-running the held events,
|
|
2335
|
+
// or releases them; it never leaves a live process idle on unacked work.
|
|
2336
|
+
let heldRetry = [];
|
|
2337
|
+
// eventId → consecutive local failures, for HELD_RETRY_MAX_FAILURES.
|
|
2338
|
+
const heldRetryFailures = new Map();
|
|
2339
|
+
let heldRenewTimer = null;
|
|
2340
|
+
const clearHeldRenewal = () => {
|
|
2341
|
+
if (heldRenewTimer !== null) clearIntervalImpl(heldRenewTimer);
|
|
2342
|
+
heldRenewTimer = null;
|
|
2343
|
+
};
|
|
2344
|
+
|
|
2345
|
+
// Renew the nonce-bound leases of carried events. Returns the ids the server
|
|
2346
|
+
// confirmed are still this runner's delivery, or null when it cannot say:
|
|
2347
|
+
// nothing carried a nonce, the request failed, or an older server answered
|
|
2348
|
+
// without a `renewed` list.
|
|
2349
|
+
const renewHeldDeliveries = async (events) => {
|
|
2350
|
+
const deliveries = events.filter(deliveryIdOf)
|
|
2351
|
+
.map((event) => ({ eventId: String(event._id), deliveryId: deliveryIdOf(event) }));
|
|
2352
|
+
if (deliveries.length === 0) return null;
|
|
2353
|
+
const confirmed = new Set();
|
|
2354
|
+
try {
|
|
2355
|
+
for (let offset = 0; offset < deliveries.length; offset += 50) {
|
|
2356
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2357
|
+
const response = await client.post('/api/agents/runtime/events/renew', {
|
|
2358
|
+
events: deliveries.slice(offset, offset + 50),
|
|
2359
|
+
});
|
|
2360
|
+
if (!Array.isArray(response?.renewed)) return null;
|
|
2361
|
+
for (const id of response.renewed) confirmed.add(String(id));
|
|
2362
|
+
}
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
log(`[inbox.batch] held delivery renewal failed: ${error.message}`);
|
|
2365
|
+
return null;
|
|
2366
|
+
}
|
|
2367
|
+
return confirmed;
|
|
2368
|
+
};
|
|
2369
|
+
// A nonce-bound event the server did not renew is no longer ours to ack:
|
|
2370
|
+
// the child checkpointed it mid-turn, the requeue took it, or the cap
|
|
2371
|
+
// retired it. Re-running it would at best repeat finished work, and at worst
|
|
2372
|
+
// end in a stale-delivery ack that stops the run.
|
|
2373
|
+
const stillHeld = (event, confirmed) => (
|
|
2374
|
+
confirmed === null || !deliveryIdOf(event) || confirmed.has(String(event._id))
|
|
2375
|
+
);
|
|
2376
|
+
|
|
2377
|
+
// Decide what a failed turn's retry carries. `failedEvents` were attempted
|
|
2378
|
+
// and count toward HELD_RETRY_MAX_FAILURES; `untriedEvents` are later groups
|
|
2379
|
+
// from the same page that the failure stopped before they ran (#782), and
|
|
2380
|
+
// are just as claimed, so they ride along without a strike.
|
|
2381
|
+
const holdForRetry = async (failedEvents, untriedEvents = []) => {
|
|
2382
|
+
const released = [];
|
|
2383
|
+
const carry = [];
|
|
2384
|
+
const seen = new Set();
|
|
2385
|
+
for (const event of failedEvents) {
|
|
2386
|
+
const id = String(event._id);
|
|
2387
|
+
if (!seen.has(id)) {
|
|
2388
|
+
seen.add(id);
|
|
2389
|
+
const failures = (heldRetryFailures.get(id) || 0) + 1;
|
|
2390
|
+
if (failures >= HELD_RETRY_MAX_FAILURES) released.push(event);
|
|
2391
|
+
else {
|
|
2392
|
+
heldRetryFailures.set(id, failures);
|
|
2393
|
+
carry.push(event);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
for (const event of untriedEvents) {
|
|
2398
|
+
const id = String(event._id);
|
|
2399
|
+
if (!seen.has(id)) {
|
|
2400
|
+
seen.add(id);
|
|
2401
|
+
carry.push(event);
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
// A stopping run carries nothing and renews nothing: extending the lease
|
|
2405
|
+
// of a process on its way out only delays the replacement's redelivery.
|
|
2406
|
+
const confirmed = running && carry.length ? await renewHeldDeliveries(carry) : null;
|
|
2407
|
+
const held = running ? carry.filter((event) => stillHeld(event, confirmed)) : [];
|
|
2408
|
+
const dropped = running ? carry.filter((event) => !stillHeld(event, confirmed)) : [];
|
|
2409
|
+
const heldIds = new Set(held.map((event) => String(event._id)));
|
|
2410
|
+
for (const id of [...heldRetryFailures.keys()]) {
|
|
2411
|
+
if (!heldIds.has(id)) heldRetryFailures.delete(id);
|
|
2412
|
+
}
|
|
2413
|
+
heldRetry = held;
|
|
2414
|
+
if (dropped.length) {
|
|
2415
|
+
log(
|
|
2416
|
+
`[inbox.batch] ${countEvents(dropped.length)} no longer held by this runner on the server `
|
|
2417
|
+
+ '(checkpointed, requeued or retired) — not retried',
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
return { held, released };
|
|
2421
|
+
};
|
|
2422
|
+
|
|
2423
|
+
// Keep a long wait's leases alive. A wait shorter than one renewal interval
|
|
2424
|
+
// needs none: holdForRetry renewed at the start, and the re-run turn renews
|
|
2425
|
+
// its own batch from there.
|
|
2426
|
+
const keepHeldLeases = (delayMs) => {
|
|
2427
|
+
clearHeldRenewal();
|
|
2428
|
+
if (!running || delayMs <= DELIVERY_RENEW_INTERVAL_MS || !heldRetry.some(deliveryIdOf)) return;
|
|
2429
|
+
heldRenewTimer = setIntervalImpl(async () => {
|
|
2430
|
+
if (!running) {
|
|
2431
|
+
clearHeldRenewal();
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const carry = heldRetry;
|
|
2435
|
+
const confirmed = await renewHeldDeliveries(carry);
|
|
2436
|
+
// The tick may already have taken this carry; never resurrect it.
|
|
2437
|
+
if (heldRetry !== carry || confirmed === null) return;
|
|
2438
|
+
heldRetry = carry.filter((event) => stillHeld(event, confirmed));
|
|
2439
|
+
if (heldRetry.length < carry.length) {
|
|
2440
|
+
log(
|
|
2441
|
+
`[inbox.batch] ${countEvents(carry.length - heldRetry.length)} no longer held by this runner `
|
|
2442
|
+
+ 'on the server during the retry wait — not retried',
|
|
2443
|
+
);
|
|
2444
|
+
}
|
|
2445
|
+
}, DELIVERY_RENEW_INTERVAL_MS);
|
|
2446
|
+
heldRenewTimer?.unref?.();
|
|
2447
|
+
};
|
|
2448
|
+
|
|
2449
|
+
// What the retry will actually do, for the failure report.
|
|
2450
|
+
const describeHold = ({ held, released }, retry) => {
|
|
2451
|
+
const parts = [];
|
|
2452
|
+
if (retry.circuitOpen) parts.push('circuit open');
|
|
2453
|
+
if (released.length) {
|
|
2454
|
+
parts.push(
|
|
2455
|
+
`releasing ${countEvents(released.length)} after ${HELD_RETRY_MAX_FAILURES} failed attempts `
|
|
2456
|
+
+ 'to the server\'s requeue (redelivered in ~10–20 min)',
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
parts.push(held.length
|
|
2460
|
+
? `retrying ${held.length} held event${held.length === 1 ? '' : 's'} in ${formatRetryDelay(retry.delayMs)}`
|
|
2461
|
+
: `next poll in ${formatRetryDelay(retry.delayMs)}`);
|
|
2462
|
+
return parts.join('; ');
|
|
2463
|
+
};
|
|
2464
|
+
|
|
2304
2465
|
const handleEventBatch = async (events, inboxCount = events.length) => {
|
|
2305
2466
|
const eventPodId = events[0]?.podId || podId;
|
|
2306
2467
|
if (!eventPodId || events.some((event) => (event.podId || podId) !== eventPodId)) {
|
|
@@ -2578,7 +2739,7 @@ export const performRun = ({
|
|
|
2578
2739
|
log(`[inbox.batch] delivery renewal failed: ${error.message}`);
|
|
2579
2740
|
} finally { renewing = false; }
|
|
2580
2741
|
};
|
|
2581
|
-
const timer = deliveries.length ? setIntervalImpl(renew,
|
|
2742
|
+
const timer = deliveries.length ? setIntervalImpl(renew, DELIVERY_RENEW_INTERVAL_MS) : null;
|
|
2582
2743
|
const stopRenewal = () => {
|
|
2583
2744
|
renewalStopped = true;
|
|
2584
2745
|
if (timer !== null) clearIntervalImpl(timer);
|
|
@@ -2600,6 +2761,7 @@ export const performRun = ({
|
|
|
2600
2761
|
if (!running) return;
|
|
2601
2762
|
let nextPollDelayMs = intervalMs;
|
|
2602
2763
|
let completedModelTurn = false;
|
|
2764
|
+
let retryScheduled = false;
|
|
2603
2765
|
try {
|
|
2604
2766
|
// ADR-024 D3: this is an inbox fetch, not a stream of interrupts. The
|
|
2605
2767
|
// backend returns one pod's oldest pending batch, so every delivered row
|
|
@@ -2615,15 +2777,25 @@ export const performRun = ({
|
|
|
2615
2777
|
// returns multiple pods in one page. Preserve pod privacy in that
|
|
2616
2778
|
// migration window by partitioning locally. Current servers produce one
|
|
2617
2779
|
// group, so the normal path remains one tick → one turn.
|
|
2618
|
-
// A carried greeting rejoins the inbox here, so it is
|
|
2619
|
-
// and acked by exactly the same path as anything the
|
|
2620
|
-
// carried row is still 'delivered' on the server, so a
|
|
2621
|
-
// carry can hand the same id back in this very page:
|
|
2622
|
-
// than processing it twice.
|
|
2623
|
-
|
|
2780
|
+
// A carried greeting or failed batch rejoins the inbox here, so it is
|
|
2781
|
+
// grouped, ordered and acked by exactly the same path as anything the
|
|
2782
|
+
// poll returned. The carried row is still 'delivered' on the server, so a
|
|
2783
|
+
// requeue during the carry can hand the same id back in this very page:
|
|
2784
|
+
// drop the carried copy rather than processing it twice. The fetched
|
|
2785
|
+
// copy wins because it carries the live deliveryId; the carried one's
|
|
2786
|
+
// nonce was cleared by that requeue.
|
|
2787
|
+
const carried = [...heldRetry, ...heldGreeting];
|
|
2624
2788
|
heldGreeting = [];
|
|
2789
|
+
heldRetry = [];
|
|
2790
|
+
clearHeldRenewal();
|
|
2625
2791
|
const fetchedIds = new Set(events.map((event) => String(event._id)));
|
|
2626
|
-
const
|
|
2792
|
+
const carriedIds = new Set();
|
|
2793
|
+
const carriedFresh = carried.filter((event) => {
|
|
2794
|
+
const id = String(event._id);
|
|
2795
|
+
if (fetchedIds.has(id) || carriedIds.has(id)) return false;
|
|
2796
|
+
carriedIds.add(id);
|
|
2797
|
+
return true;
|
|
2798
|
+
});
|
|
2627
2799
|
const pageEvents = carriedFresh.length ? [...carriedFresh, ...events] : events;
|
|
2628
2800
|
const pageInboxCount = (Number.isFinite(inboxCount) ? inboxCount : events.length)
|
|
2629
2801
|
+ carriedFresh.length;
|
|
@@ -2638,7 +2810,14 @@ export const performRun = ({
|
|
|
2638
2810
|
group.push(event);
|
|
2639
2811
|
eventGroups.set(key, group);
|
|
2640
2812
|
}
|
|
2641
|
-
|
|
2813
|
+
const groups = [...eventGroups.values()];
|
|
2814
|
+
// The claimed events a failure at `groupIndex` stops before they run.
|
|
2815
|
+
// A digest-only group was never claimed, so it is not carried.
|
|
2816
|
+
const untriedAfter = (groupIndex) => groups.slice(groupIndex + 1)
|
|
2817
|
+
.filter((later) => !later.every((event) => event.deliveryMode === 'digest'))
|
|
2818
|
+
.flat();
|
|
2819
|
+
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
|
|
2820
|
+
const group = groups[groupIndex];
|
|
2642
2821
|
if (!running) break;
|
|
2643
2822
|
// A current kernel never claims a digest-only page. Guard a mixed
|
|
2644
2823
|
// version rollout too: an ambient batch must not start a model turn.
|
|
@@ -2659,7 +2838,9 @@ export const performRun = ({
|
|
|
2659
2838
|
}
|
|
2660
2839
|
// One failure report for a batch whose items stay unacked, whether
|
|
2661
2840
|
// the composite turn threw or an isolated item did after a refusal.
|
|
2662
|
-
|
|
2841
|
+
// The unacked items are carried to the retry, not left to the
|
|
2842
|
+
// requeue sweep, so the delay below is when they run again.
|
|
2843
|
+
const scheduleBatchRetry = async (err, failedEvents) => {
|
|
2663
2844
|
consecutiveSpawnFailures += 1;
|
|
2664
2845
|
const retry = spawnRetryPolicy({
|
|
2665
2846
|
error: err,
|
|
@@ -2668,11 +2849,13 @@ export const performRun = ({
|
|
|
2668
2849
|
jitterRatio: spawnJitterRatio,
|
|
2669
2850
|
});
|
|
2670
2851
|
nextPollDelayMs = retry.delayMs;
|
|
2852
|
+
retryScheduled = true;
|
|
2853
|
+
const hold = await holdForRetry(failedEvents, untriedAfter(groupIndex));
|
|
2854
|
+
keepHeldLeases(retry.delayMs);
|
|
2671
2855
|
const event = group[0];
|
|
2672
2856
|
const wrapped = new Error(
|
|
2673
2857
|
`inbox batch processing failed (${retry.failureClass}; ${consecutiveSpawnFailures} consecutive) `
|
|
2674
|
-
+ `— ${
|
|
2675
|
-
+ `next probe in ${formatRetryDelay(retry.delayMs)}: ${err.message}`,
|
|
2858
|
+
+ `— ${describeHold(hold, retry)}: ${err.message}`,
|
|
2676
2859
|
{ cause: err },
|
|
2677
2860
|
);
|
|
2678
2861
|
Object.assign(wrapped, {
|
|
@@ -2681,6 +2864,8 @@ export const performRun = ({
|
|
|
2681
2864
|
consecutiveFailures: consecutiveSpawnFailures,
|
|
2682
2865
|
retryAfterMs: retry.delayMs,
|
|
2683
2866
|
circuitOpen: retry.circuitOpen,
|
|
2867
|
+
heldCount: hold.held.length,
|
|
2868
|
+
releasedCount: hold.released.length,
|
|
2684
2869
|
eventId: event._id,
|
|
2685
2870
|
...(isPodFocusDiagnostic(err) ? {
|
|
2686
2871
|
focusDiagnostic: {
|
|
@@ -2704,7 +2889,7 @@ export const performRun = ({
|
|
|
2704
2889
|
pageInboxCount,
|
|
2705
2890
|
);
|
|
2706
2891
|
} catch (err) {
|
|
2707
|
-
scheduleBatchRetry(err, group
|
|
2892
|
+
await scheduleBatchRetry(err, group);
|
|
2708
2893
|
break;
|
|
2709
2894
|
}
|
|
2710
2895
|
const batchSpawned = batch.entries.some((entry) => (
|
|
@@ -2742,7 +2927,10 @@ export const performRun = ({
|
|
|
2742
2927
|
}
|
|
2743
2928
|
}
|
|
2744
2929
|
if (batch.deferredError) {
|
|
2745
|
-
scheduleBatchRetry(
|
|
2930
|
+
await scheduleBatchRetry(
|
|
2931
|
+
batch.deferredError,
|
|
2932
|
+
batch.entries.filter((entry) => !entry.result).map((entry) => entry.event),
|
|
2933
|
+
);
|
|
2746
2934
|
break;
|
|
2747
2935
|
}
|
|
2748
2936
|
continue;
|
|
@@ -2775,12 +2963,16 @@ export const performRun = ({
|
|
|
2775
2963
|
jitterRatio: spawnJitterRatio,
|
|
2776
2964
|
});
|
|
2777
2965
|
nextPollDelayMs = retry.delayMs;
|
|
2778
|
-
|
|
2779
|
-
|
|
2966
|
+
retryScheduled = true;
|
|
2967
|
+
// Carried to the retry like a failed batch: the event is claimed,
|
|
2968
|
+
// so the next poll would not return it.
|
|
2969
|
+
// eslint-disable-next-line no-await-in-loop
|
|
2970
|
+
const hold = await holdForRetry([event], untriedAfter(groupIndex));
|
|
2971
|
+
keepHeldLeases(retry.delayMs);
|
|
2780
2972
|
const wrapped = new Error(
|
|
2781
2973
|
`${event.type} processing failed (${retry.failureClass}; `
|
|
2782
|
-
+ `${consecutiveSpawnFailures} consecutive) — event ${event._id}
|
|
2783
|
-
+ `${
|
|
2974
|
+
+ `${consecutiveSpawnFailures} consecutive) — event ${event._id} unacked; `
|
|
2975
|
+
+ `${describeHold(hold, retry)}: ${err.message}`,
|
|
2784
2976
|
{ cause: err },
|
|
2785
2977
|
);
|
|
2786
2978
|
Object.assign(wrapped, {
|
|
@@ -2789,6 +2981,8 @@ export const performRun = ({
|
|
|
2789
2981
|
consecutiveFailures: consecutiveSpawnFailures,
|
|
2790
2982
|
retryAfterMs: retry.delayMs,
|
|
2791
2983
|
circuitOpen: retry.circuitOpen,
|
|
2984
|
+
heldCount: hold.held.length,
|
|
2985
|
+
releasedCount: hold.released.length,
|
|
2792
2986
|
eventId: event._id,
|
|
2793
2987
|
...(isPodFocusDiagnostic(err) ? {
|
|
2794
2988
|
focusDiagnostic: {
|
|
@@ -2894,12 +3088,19 @@ export const performRun = ({
|
|
|
2894
3088
|
onError?.(err);
|
|
2895
3089
|
}
|
|
2896
3090
|
// Notices landing during a turn share one three-second collection window
|
|
2897
|
-
// before the next inbox read, independent of the idle polling cadence.
|
|
2898
|
-
|
|
3091
|
+
// before the next inbox read, independent of the idle polling cadence. A
|
|
3092
|
+
// retry scheduled in the same tick keeps its own delay: the failure report
|
|
3093
|
+
// names it, and a sibling's completed turn must not shorten the backoff.
|
|
3094
|
+
if (running) setTimeoutImpl(tick, completedModelTurn && !retryScheduled ? 3000 : nextPollDelayMs);
|
|
2899
3095
|
};
|
|
2900
3096
|
|
|
2901
3097
|
tick();
|
|
2902
|
-
return {
|
|
3098
|
+
return {
|
|
3099
|
+
stop: () => {
|
|
3100
|
+
running = false;
|
|
3101
|
+
clearHeldRenewal();
|
|
3102
|
+
},
|
|
3103
|
+
};
|
|
2903
3104
|
};
|
|
2904
3105
|
|
|
2905
3106
|
// ── init: scaffold a webhook-SDK agent (ADR-006 §Scaffolding) ───────────────
|
|
@@ -81,7 +81,7 @@ import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credent
|
|
|
81
81
|
import {
|
|
82
82
|
prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
|
|
83
83
|
} from '../mcp-home.js';
|
|
84
|
-
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
84
|
+
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
85
85
|
import { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js';
|
|
86
86
|
import { skillsAsText } from '../skill-prompt.js';
|
|
87
87
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
@@ -98,6 +98,16 @@ const DEFAULT_TIMEOUT_MS = (() => {
|
|
|
98
98
|
})();
|
|
99
99
|
|
|
100
100
|
const buildPrompt = buildMemoryPreamble;
|
|
101
|
+
|
|
102
|
+
// The working habits a private claude seat is taught on a fresh session (D48,
|
|
103
|
+
// TASK-545). Parallel reads live here because only this CLI has a subagent
|
|
104
|
+
// tool; codex is passed row slicing alone. A public seat is passed neither: its
|
|
105
|
+
// argv allows Read and the MCP under dontAsk, so it has no subagent to spawn,
|
|
106
|
+
// and it answers strangers rather than working board rows.
|
|
107
|
+
const PARALLEL_READS_CUE = 'Parallel reads: when a row needs several files or sources read, '
|
|
108
|
+
+ 'use the Agent tool. Spawn read-only subagents in parallel, brief each one with the files '
|
|
109
|
+
+ 'to read and the question to answer, and keep only their conclusions.';
|
|
110
|
+
const WORK_CUES = [PARALLEL_READS_CUE, ROW_SLICING_CUE];
|
|
101
111
|
const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
|
|
102
112
|
|
|
103
113
|
const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
@@ -799,7 +809,12 @@ export default {
|
|
|
799
809
|
// the only two paths that build a real prompt. buildPrompt handles
|
|
800
810
|
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
801
811
|
// the value.
|
|
802
|
-
const
|
|
812
|
+
const publicTrust = ctx.environment?.sandbox?.trust === 'public';
|
|
813
|
+
const workCues = publicTrust ? [] : WORK_CUES;
|
|
814
|
+
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
815
|
+
freshSession: !isResume,
|
|
816
|
+
workCues,
|
|
817
|
+
});
|
|
803
818
|
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
804
819
|
// Model pin from the ADR-008 environment spec. Absent it, claude picks its
|
|
805
820
|
// own default — which is how a fleet of ten agents ended up running three
|
|
@@ -839,7 +854,6 @@ export default {
|
|
|
839
854
|
let mcpConfig = null;
|
|
840
855
|
let publicClaudeState = null;
|
|
841
856
|
let publicSkillArgs = [];
|
|
842
|
-
const publicTrust = ctx.environment?.sandbox?.trust === 'public';
|
|
843
857
|
const publicNativeSandbox = publicTrust
|
|
844
858
|
&& PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
|
|
845
859
|
const publicBwrapSandbox = publicTrust
|
|
@@ -927,7 +941,10 @@ export default {
|
|
|
927
941
|
// The retry creates a new underlying CLI session. Rebuild its prompt
|
|
928
942
|
// as fresh too: otherwise a session-recovery path is the one fresh
|
|
929
943
|
// session that misses the durable-state cue.
|
|
930
|
-
const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
944
|
+
const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
945
|
+
freshSession: true,
|
|
946
|
+
workCues,
|
|
947
|
+
});
|
|
931
948
|
const retryBase = ['-p', freshPrompt,
|
|
932
949
|
'--output-format', 'text', '--session-id', freshId, ...modelArgs];
|
|
933
950
|
const retryCtx = {
|
|
@@ -66,7 +66,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
|
66
66
|
import { supportedGitHooks } from '../commit-attribution.js';
|
|
67
67
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
68
68
|
import { prepareMcpSpawn, publicMcpUnavailableError, selectedMcpVersionDir } from '../mcp-home.js';
|
|
69
|
-
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
69
|
+
import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
|
|
70
70
|
import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
|
|
71
71
|
import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
|
|
72
72
|
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
@@ -650,14 +650,20 @@ export default {
|
|
|
650
650
|
|
|
651
651
|
async spawn(prompt, ctx = {}) {
|
|
652
652
|
const seatModel = requireSeatModel(ctx.environment, 'codex');
|
|
653
|
+
const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
|
|
653
654
|
// Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
|
|
654
655
|
// `null || ''` is `''` — which routed the unreadable case straight into
|
|
655
656
|
// the empty-memory branch and made the whole distinction unreachable from
|
|
656
657
|
// the only two paths that build a real prompt. buildPrompt handles
|
|
657
658
|
// undefined and '' as absence itself; it does not need a guard, it needs
|
|
658
659
|
// the value.
|
|
660
|
+
//
|
|
661
|
+
// Codex has no subagent tool, so of D48's two habits (TASK-545) it is
|
|
662
|
+
// taught row slicing alone; claude.js adds parallel reads. A public seat
|
|
663
|
+
// answers strangers rather than working board rows, so it is taught neither.
|
|
659
664
|
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
|
|
660
665
|
freshSession: !ctx.sessionId,
|
|
666
|
+
workCues: sandbox?.trust === 'public' ? [] : [ROW_SLICING_CUE],
|
|
661
667
|
});
|
|
662
668
|
|
|
663
669
|
// Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
|
|
@@ -674,7 +680,6 @@ export default {
|
|
|
674
680
|
});
|
|
675
681
|
|
|
676
682
|
try {
|
|
677
|
-
const sandbox = normalizeSandboxTrust(ctx.environment?.sandbox);
|
|
678
683
|
const mcp = buildMcpOverrideArgs(ctx.environment?.mcp, {
|
|
679
684
|
runtimeToken: ctx.runtimeToken,
|
|
680
685
|
instanceUrl: ctx.instanceUrl,
|
package/src/lib/memory-bridge.js
CHANGED
|
@@ -22,6 +22,18 @@
|
|
|
22
22
|
|
|
23
23
|
export const SOURCE_RUNTIME = 'local-cli';
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Row slicing, the runtime-agnostic half of D48 (TASK-545). A row too large
|
|
27
|
+
* for one turn drifts when one seat carries it whole, so the seat splits it on
|
|
28
|
+
* the board before any code. It names the exact call and field for the reason
|
|
29
|
+
* the memory cue below names `long_term`: a seat adopts a habit through a
|
|
30
|
+
* surface it can see. Each adapter decides whether to pass it.
|
|
31
|
+
*/
|
|
32
|
+
export const ROW_SLICING_CUE = 'Slicing: when a row is too large for one turn, split it before '
|
|
33
|
+
+ 'writing any code. Post each slice as a child row (commonly_create_task with parentTask set '
|
|
34
|
+
+ "to the row's taskId) with one assignee, a written file or area boundary, and its own "
|
|
35
|
+
+ 'acceptance line.';
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* The turn preamble, shared by every adapter so the two cannot drift.
|
|
27
39
|
*
|
|
@@ -39,8 +51,17 @@ export const SOURCE_RUNTIME = 'local-cli';
|
|
|
39
51
|
* the section whose name most invites exactly this use — succeeds, returns 200,
|
|
40
52
|
* and is never seen again; the write and the silence are indistinguishable from
|
|
41
53
|
* a correct round trip. Naming the section is the whole point of the cue.
|
|
54
|
+
*
|
|
55
|
+
* `workCues` are working habits an adapter teaches on a fresh session, beside
|
|
56
|
+
* the memory cues and under the same once-per-session rule. An adapter passes
|
|
57
|
+
* only the habits its CLI can act on; passing none leaves the preamble as it
|
|
58
|
+
* was.
|
|
42
59
|
*/
|
|
43
|
-
export const buildMemoryPreamble = (
|
|
60
|
+
export const buildMemoryPreamble = (
|
|
61
|
+
prompt,
|
|
62
|
+
memoryLongTerm,
|
|
63
|
+
{ freshSession = false, workCues = [] } = {},
|
|
64
|
+
) => {
|
|
44
65
|
// `null` is the UNREADABLE signal, and it is deliberately not the same value
|
|
45
66
|
// as `''`. Telling a seat whose token was revoked that "nothing has ever been
|
|
46
67
|
// saved here" is a false claim about its own history, and it is the same
|
|
@@ -87,7 +108,10 @@ export const buildMemoryPreamble = (prompt, memoryLongTerm, { freshSession = fal
|
|
|
87
108
|
+ 'gates held, decisions pending, and task context, not a transcript — using '
|
|
88
109
|
+ 'the long_term write above.';
|
|
89
110
|
}
|
|
90
|
-
|
|
111
|
+
const workCue = workCues.length
|
|
112
|
+
? `\n=== Working a row ===\n${workCues.map((cue) => `- ${cue}`).join('\n')}\n`
|
|
113
|
+
: '';
|
|
114
|
+
return `${context}\n${freshReadCue}${workCue}\n=== Current turn ===\n${prompt}\n${sessionEndCue}`;
|
|
91
115
|
};
|
|
92
116
|
|
|
93
117
|
export const readLongTerm = async (client, { onError } = {}) => {
|