toga-ai 1.0.708 → 1.0.709

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.
@@ -17,7 +17,7 @@
17
17
  | [ClickUp Task Description Fluffer (Talos KB Investigation)](features/clickup-task-fluffer-talos.md) | `_Worker_Clickup_Fluffer` "fluffs" a ClickUp task by sending its name + existing description to the **Talos `dev-core` KB-investigation agent** and writing the | worker2/Worker/Clickup/Fluffer.php, worker2/Worker/Clickup.php, worker2/Config/production.ini, worker2/Config/beta.ini, worker2/Config/dev-rohan-mac.ini |
18
18
  | [ClickUp Work Type Automation (Committed / Conditional / Stretch)](features/clickup-work-type-automation.md) | The ClickUp webhook handler (`_Worker_Clickup`) automatically maintains each task's **Work Type** custom field — `Committed`, `Conditional`, or `Stretch` — base | worker2/Worker/Clickup.php, worker2/Tests/Worker/ClickupWorkTypeTest.php |
19
19
  | [Compass VIP Support Importer (worker2)](features/compass-vip-support-importer.md) | A worker2 action that ingests Compass's quarterly VIP spreadsheet and assigns each VIP user's support technician by setting `Users.c_supportedByUserId` in `Clie | worker2/Worker/Client/Compass/VipSupport.php |
20
- | [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
20
+ | [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, worker2/LambdaFunctions/JobScheduler/lambda_function.py, _underscore/Worker.php |
21
21
  | [Cross-account AWS access for worker2 crons (_Component_Aws_Workloads)](features/cross-account-aws-access.md) | `_Component_Aws_Workloads` is the **standard, and only sanctioned, way any new worker2 cron obtains AWS access** — for any account, any region, any AWS SDK clie | worker2/Component/Aws/Workloads/Workloads.php, worker2/Config/production.ini, worker2/Worker/Infrastructure/CloudWatch.php, worker2/Worker/Monitor/Fleet.php, library/app/worker.php, library/app/cloud.php |
22
22
  | [Elastic Beanstalk health monitor → OneUptime (ElasticBeanstalkHealth)](features/elastic-beanstalk-health-monitor.md) | `_Worker_Infrastructure_CloudWatch::ElasticBeanstalkHealth()` is a worker2 cron that reads each Elastic Beanstalk (EB) environment's health and reports it to a | worker2/Worker/Infrastructure/CloudWatch.php |
23
23
  | [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-29
10
- owners: [jcardinal]
9
+ updated: 2026-08-31
10
+ owners: [jcardinal, ajean]
11
11
  files:
12
12
  - worker2/Controller/Index.php
13
13
  - worker2/Worker/
@@ -62,7 +62,9 @@ HTTP-triggered job endpoints must enforce auth.
62
62
  `Core.WorkerJobs`, so never treat a queue message as the record of a job. Every worker/webhook
63
63
  endpoint must return **HTTP 200 on every path, including failure** (failures are recorded in
64
64
  `WorkerJobs`): there is no DLQ and the visibility timeout is 3600s, so a single 500 becomes a
65
- poison-message storm. Workers are `abstract class _Worker_<Name>` with `public static` entry
65
+ poison-message storm — but 3600s is **delivery**, not an execution budget: the watchdog fails any
66
+ job past `maxExecutionTime`, and that override only applies to CronScheduler-fired jobs, so every
67
+ webhook / `runTask` / curl action is hard-capped at **300s** and anything longer must self-chunk. Workers are `abstract class _Worker_<Name>` with `public static` entry
66
68
  methods and are invoked **only** through `_Worker::runTask(action, parameters)` — never call a
67
69
  worker method directly. This repo is PHP and Python only: never add a `.sql` file here, as all
68
70
  schema changes and cron seeds belong to `dbchanges2`. Both environments run **PHP 8.5**, and the
@@ -127,8 +129,18 @@ fields on other tables: the `Email` table (`Worker/Infrastructure/Email/Send.php
127
129
  2. **PayloadRecovery** — every minute, recovers S3 fallback payloads into `WorkerJobs`.
128
130
  3. **JobScheduler** — every minute. **Part A:** picks up `dtQueued IS NULL AND isSuccess
129
131
  IS NULL` (≤100), sets `dtQueued=NOW()`, sends to queue. **Part B (watchdog):** marks
130
- jobs `isSuccess=0` when `NOW() > dtStarted + timeout` (CronJobs.maxExecutionTime, else
131
- env `MAX_EXECUTION_TIME_WORKER_ACTION` default 300).
132
+ jobs `isSuccess=0` when `NOW() > dtStarted + timeout` with `dtCompleted IS NULL`.
133
+ ⚠ **The `CronJobs.maxExecutionTime` override is reachable ONLY for scheduler-fired jobs.**
134
+ Part B resolves the timeout through `LEFT JOIN CronJobs cj ON wj.cronJobId = cj.id`, and
135
+ `cronJobId` is NULL on every job the CronScheduler did not fire — webhooks, `_Worker::runTask`
136
+ enqueues, direct `curl`. Those are **all** capped at env `MAX_EXECUTION_TIME_WORKER_ACTION`
137
+ (default 300s) with **no per-job override**; inserting a `CronJobs` row with a larger
138
+ `maxExecutionTime` changes nothing unless the scheduler is what fires the job. And the watchdog
139
+ only writes to the row — **it does not stop the PHP process**, so an over-budget action keeps
140
+ running past a false failure row and typically loses its result `UPDATE` to MySQL 2006 "server
141
+ has gone away" (prod job 1075223, 2026-08-28: failed at 326s, worked to 818s, report lost).
142
+ Long work must self-chunk — see
143
+ [creating-worker-actions](./features/creating-worker-actions.md#self-chunking-the-pattern-for-work-that-cannot-finish-in-one-job).
132
144
  4. **CronScheduler** — every minute, evaluates active `CronJobs.schedule` against the
133
145
  current Central-tz minute (`croniter`); on match INSERTs a `CRON` job and queues it.
134
146
 
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-08-31
10
10
  owners: [jcardinal, ajean]
11
11
  files:
12
12
  - worker2/Worker/Clickup/Project.php
@@ -14,6 +14,7 @@ files:
14
14
  related:
15
15
  - ../architecture.md
16
16
  - ./creating-worker-actions.md
17
+ - ./netsuite-opportunity-sync.md
17
18
  - ./clickup-connectivity-watchdog.md
18
19
  - ./clickup-subtask-activity.md
19
20
  ---
@@ -148,6 +149,17 @@ None — uniform across all clients (shared internal automation for Agilant's Cl
148
149
  - **POST failures are fatal, DELETE failures are tolerated** — a failed ADD leaves the task
149
150
  out of its correct list (surfaced as job failure); a failed DELETE (most likely TASK_035,
150
151
  which shouldn't occur on secondary lists) is logged with `[ClickupProject]` and skipped.
152
+ - **The unowned side of the hybrid split bites triage: home-list moves happen that no code explains.**
153
+ Because the worker provably cannot move a home list (TASK_035), any observed home-list change is
154
+ either a ClickUp native **Automation** or a **human** — and neither is visible in our logs, nor
155
+ reliably readable through the API. Live case 2026-08-31: opportunity tasks created by the NetSuite
156
+ sync into `901111987449` ("Presales Qualification") were found homed in `901111987451`
157
+ ("Quote/Proposal/SOW/MSA") in the same folder `90117060687` ("Opportunities"), space `90113928591`
158
+ — and the movement was **not cleanly stage-driven** (two tasks created the same day at the same
159
+ Stage 25%, only one moved), so the mover is **unidentified**. Practical rule: **never assess a
160
+ pipeline's health by the contents of one list**; assess by task id or by the whole folder. Detail
161
+ and evidence in
162
+ [NetSuite ↔ ClickUp Opportunity Sync](./netsuite-opportunity-sync.md#triage-is-the-opportunity-sync-working-workerjobsoutput-verdicts).
151
163
  - **Webhook subscriptions** must cover each space, all → `webhook.togahub.com/clickup`. Routing:
152
164
  legacy sprint space `90020178491`, Project Hub `90113939341`, Lifecycle Services
153
165
  `90114156087`; Opportunity Hub via `RegisterOpportunityWebhook()`. The hub guard additionally
@@ -164,6 +176,11 @@ Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/
164
176
  `RESULT: PASS`), `RegisterOpportunityWebhook` and `RegisterHubGuardWebhooks` (one-time setup).
165
177
 
166
178
  ## Change history
179
+ - 2026-08-31 — Recorded the triage consequence of the hybrid split (read-only investigation, no code
180
+ changed): worker-created opportunity tasks were observed **homed in `901111987451`
181
+ "Quote/Proposal/SOW/MSA"** rather than the `901111987449` list they were POSTed to, with the mover
182
+ **unidentified** (not cleanly stage-driven — ClickUp Automation or a human). Rule added: judge a
183
+ pipeline's health by task id or folder, never by one list. (ajean)
167
184
  - 2026-07-14 — Correction: `TASK_TYPE_EPIC_ID` / `TASK_TYPE_OPPORTUNITY_ID` are still `null` and the Task-Types ClickApp is not reliably enabled on the hub spaces, so `enforceHubPlacement`'s custom-Task-Type keying is inert; structural (space + top-level) identification is what actually runs. Cross-linked the new subtask-activity feature (same hub spaces/pipeline). (ajean)
168
185
  - 2026-06-23 — Added the hub-placement guard (`enforceHubPlacement` on `taskCreated`/`taskMoved`): re-anchors misplaced Epics/Opportunities to their hub by custom Task Type + home space, backfills Business Unit/Project Category, and notifies. Added `DiscoverTaskTypes`, `RegisterHubGuardWebhooks`, `TestHubGuardMappings`. Corrected the earlier "Task Types not enabled" note. (ajean)
169
186
  - 2026-06-09 — Documented ClickUp secondary multi-list routing (hybrid native-automation + worker design, three space handlers, self-trigger guard). (jcardinal)
@@ -6,14 +6,16 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-31
10
10
  owners: [jcardinal, dfranks, mhammontree, tcox, bala, ajean]
11
11
  files:
12
12
  - worker2/Worker/
13
13
  - worker2/Controller/Index.php
14
+ - worker2/LambdaFunctions/JobScheduler/lambda_function.py
14
15
  - _underscore/Worker.php
15
16
  related:
16
17
  - ../architecture.md
18
+ - ./netsuite-opportunity-client-labels.md
17
19
  - ../../api2/features/api-payload-interceptors.md
18
20
  ---
19
21
 
@@ -203,6 +205,103 @@ So **do not design "throw so it retries" — it won't.** To actually retry / sel
203
205
  Rule of thumb: `throw` to surface a genuine failure; `runTask`/in-process loop when the work should
204
206
  be reattempted.
205
207
 
208
+ ## ⚠ The real time budget is 300s — and for a hand-triggered action it CANNOT be raised
209
+
210
+ The 3600s SQS visibility timeout is **not** your budget, and the worker2 docs previously implied it
211
+ was. The **JobScheduler watchdog** (`LambdaFunctions/JobScheduler/lambda_function.py`, Part B) fails
212
+ any job whose `dtStarted` is older than its timeout while `dtCompleted IS NULL` — and it resolves
213
+ that timeout from `CronJobs.maxExecutionTime` **only through
214
+ `LEFT JOIN CronJobs cj ON wj.cronJobId = cj.id`**.
215
+
216
+ `cronJobId` is **NULL for anything the CronScheduler did not fire** — every webhook job, every
217
+ `_Worker::runTask()` enqueue, every `curl` to the worker. All of those fall back to env
218
+ `MAX_EXECUTION_TIME_WORKER_ACTION`, **default 300s**, and there is **no per-job override**: adding a
219
+ `Core.CronJobs` row with a large `maxExecutionTime` buys you nothing unless the **scheduler** is the
220
+ thing that fires the job (only then is `cronJobId` populated). "I gave the job an hour" is not
221
+ something you can do for an ad-hoc action.
222
+
223
+ Worse, the watchdog only writes to the row — **the PHP process keeps running**. Measured in
224
+ production (2026-08-28, job `1075223`, a full `OpportunityStakeholderBackfill` dry run):
225
+
226
+ | t | what happened |
227
+ |---|---|
228
+ | 326s | watchdog stamps `isSuccess=0`, `output = 'watchdog: exceeded maxExecutionTime without completing'` |
229
+ | 326–818s | the action carries on working, unaware it has been declared dead |
230
+ | 818s | its own result `UPDATE` dies on MySQL **2006 "server has gone away"** (`Logs.Issue` ref 6E) — the entire report is lost; HTTP returned 500 carrying only `Error reference: 6E-190` |
231
+
232
+ So an over-budget action loses its output **twice**: once to a false failure row, once to a dropped
233
+ connection. Design for 300s.
234
+
235
+ ### Self-chunking: the pattern for work that cannot finish in one job
236
+
237
+ An action that may exceed the budget works for a **sub-budget slice**, then **re-enqueues itself**
238
+ via `_Worker::runTask()` carrying its cursor and running totals in a `resume` parameter. One
239
+ `WorkerJobs` row per chunk, each labelled so a mid-run row is never misread as the final answer
240
+ (`'chunk 4, run CONTINUES'` vs `'run COMPLETE'`). Proven by
241
+ `Worker/Netsuite/OpportunityStakeholderBackfill.php` (`CHUNK_TIME_BUDGET_SECONDS = 180`; live passes
242
+ of 5–11 chunks, 15–32 minutes, 0 failures) — see
243
+ [netsuite-opportunity-client-labels](./netsuite-opportunity-client-labels.md).
244
+
245
+ ```php
246
+ const CHUNK_TIME_BUDGET_SECONDS = 180;
247
+
248
+ public static function Backfill(array|object $resume = []): string {
249
+ $startedAt = microtime(true); // TOP OF THE JOB, not top of the loop
250
+ $resume = (array) json_decode(json_encode($resume), true);
251
+ $cursor = (int) ($resume['cursor'] ?? 0);
252
+ $totals = $resume['totals'] ?? [];
253
+
254
+ $rows = self::fetchBatch($cursor); // keyset: WHERE o.id > $cursor ORDER BY o.id ASC
255
+ _Database::transactionCommit(self::SOURCE_DB); // commit the read BEFORE any remote call
256
+
257
+ if (!$rows) return self::report($totals, complete: true);
258
+
259
+ $processed = 0;
260
+ foreach ($rows as $row) {
261
+ // forward-progress invariant: always do at least one before honouring the deadline
262
+ if ($processed > 0 && (microtime(true) - $startedAt) >= self::CHUNK_TIME_BUDGET_SECONDS) break;
263
+ self::processOne($row, $totals);
264
+ $cursor = (int) $row->id;
265
+ $processed++;
266
+ }
267
+
268
+ _Worker::runTask(self::ACTION, ['resume' => ['cursor' => $cursor, 'totals' => $totals]]);
269
+ return self::report($totals, complete: false);
270
+ }
271
+ ```
272
+
273
+ Five design points, each of which was a real bug or near-miss:
274
+
275
+ - **Measure the deadline from the top of the JOB, not the top of the loop.** Startup work (option-list
276
+ fetch, alias load) then sits *inside* the budget instead of being added on top of it.
277
+ - **Use a KEYSET cursor (`o.id > $cursor` + an explicit `ORDER BY o.id ASC`), never `OFFSET`.** Rows
278
+ enter the working set continuously, so an `OFFSET` silently skips or repeats rows when a new one
279
+ lands mid-run. A query with **no `ORDER BY`** is harmless when read once and *meaningless* across
280
+ jobs — chunking a paginated query is exactly when a missing `ORDER BY` starts corrupting results.
281
+ - **Enforce a forward-progress invariant: every chunk must advance the cursor by ≥1 unit of work.**
282
+ Without it, a chunk whose budget is already blown at loop start processes nothing, leaves the
283
+ cursor unchanged, and re-enqueues an identical job — an **unbounded chain of production jobs**.
284
+ - **Bound the carried detail by entry count AND per-entry length.** A count cap alone does not bound
285
+ bytes: failure entries embed API exception messages. Truncate by walking the tail back until
286
+ `json_encode()` succeeds — a severed multi-byte character makes `json_encode()` return `false` for
287
+ the *whole* message, and `runTask` would then hand SQS an empty body.
288
+ - **Commit the source-DB read as soon as the rows are in hand** — before any remote call and before
289
+ the early returns. Every run ends on an empty-batch hop, which otherwise leaves a lazy transaction
290
+ open (see [_underscore architecture](../../_underscore/architecture.md)).
291
+
292
+ ### Do NOT retry the chunk hand-off — a failed hand-off must break the chain
293
+
294
+ `_Worker::runTask()` **mints the idempotency uuid itself, per call** (`_underscore/Worker.php`), and
295
+ that uuid is the only key the dispatcher's dedupe guard matches on. So a send that reaches SQS and
296
+ then throws on the way back would be retried under a **different** uuid, the guard would not match,
297
+ and **both** messages run: the chain **forks** into two chains from one cursor, each spawning its own
298
+ successors — double downstream load and two disagreeing `COMPLETE` reports.
299
+
300
+ A broken chain is strictly safer: it is loud, it reports the restart cursor, and it costs one manual
301
+ restart. **Single attempt only, deliberately.** Making a retry safe requires threading a stable
302
+ per-cursor uuid through `runTask` *and* the `Controller/Index.php` dedupe — shared framework core,
303
+ cross-repo, and out of scope for a caller. Do not "fix" this back to a retry loop.
304
+
206
305
  ## `debug_mode` changes how `runTask` dispatches — and the debug path can't do read-after-write
207
306
 
208
307
  `_Worker::runTask` has two completely different behaviours:
@@ -280,6 +379,18 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
280
379
  to PHP arrays fine — only JSON **objects** become `stdClass`.) This bit
281
380
  `_Worker_Infrastructure_CloudWatch::ElasticBeanstalkHealth`'s `environments` region-map in
282
381
  production — see [cross-account-aws-access.md](./cross-account-aws-access.md).
382
+ **This applies to BOTH dispatch paths, so no invocation style escapes it:** the stored-row path
383
+ (`Controller/Index.php` ~L318) and the direct-payload/`runTask` path (~L455) each do a shallow
384
+ `(array) json_decode($job->parameters)`. So a structured parameter breaks on the **first**
385
+ dispatch whether it came from a cron row, a `curl`, or a `runTask` re-enqueue.
386
+ It is also **asymmetric on the return trip**: an associative array you send comes back
387
+ `stdClass`, while a PHP **list** comes back a plain array — so a shallow `(array)` cast is not
388
+ enough for anything nested. Verified on PHP 8.5: `null`, `array` and `object` all `TypeError`
389
+ against a userland `string`/`array` parameter, while **ints coerce silently** (so a wrong-typed
390
+ numeric fails *later*, not at dispatch). **Fix pattern:** declare `array|object` and normalize
391
+ with a full round trip — `(array) json_decode(json_encode($param), true)` — which flattens every
392
+ level at once. Any action taking a structured parameter (a `resume` cursor, a region map, an
393
+ options bag) needs this.
283
394
  - Webhook headers are **lowercased** by API Gateway (`X-GitHub-Event` → `x-github-event`).
284
395
  - If the action needs a client DB, register it in `initialize()` — it runs before the method.
285
396
  - **Worker actions do NOT auto-register `DB_CLIENT`.** The dispatcher registers only
@@ -324,6 +435,21 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
324
435
  commit-before-SQS transaction pattern that the worker relies on.
325
436
 
326
437
  ## Change history
438
+ - 2026-08-31 — Documented that **the real time budget for any hand-triggered action is 300s and
439
+ cannot be raised**: the JobScheduler watchdog only honours `CronJobs.maxExecutionTime` through
440
+ `LEFT JOIN … ON wj.cronJobId = cj.id`, and `cronJobId` is NULL for every webhook / `runTask` /
441
+ `curl` job, so they all fall back to `MAX_EXECUTION_TIME_WORKER_ACTION` (300s) — the 3600s SQS
442
+ visibility timeout is not the limit. Recorded that the watchdog does **not** stop the process
443
+ (prod job 1075223: failed at 326s, kept working to 818s, then lost its whole report to MySQL 2006
444
+ on its own result UPDATE). Added the **self-chunking pattern** for over-budget work (re-enqueue
445
+ self with a `resume` cursor; deadline from the top of the job, keyset cursor never OFFSET,
446
+ forward-progress invariant, detail bounded by count *and* bytes, commit the source read before any
447
+ remote call) and the decision **not to retry the chunk hand-off** — `runTask` mints a fresh
448
+ idempotency uuid per call, so a retry forks the chain instead of deduping. Extended the
449
+ nested-parameter gotcha: both dispatch paths cast only the top level, the return trip is
450
+ asymmetric (assoc → `stdClass`, list → array), ints coerce silently, and the fix is a
451
+ `json_encode`/`json_decode(true)` round trip. Surfaced making
452
+ `Netsuite/OpportunityStakeholderBackfill` reportable (PR #157). (ajean)
327
453
  - 2026-08-26 — Added two operational gotchas: a new action **does not run on a schedule until its
328
454
  `Core.CronJobs` row exists** (schedule is data, not code), and the practice of building a
329
455
  **dry-run entry point for a rare-gated path** rather than waiting for it to fire — one found a
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-31
10
10
  owners: ["ajean"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/Opportunity.php
@@ -97,9 +97,67 @@ the only way to populate them.
97
97
  opportunities affected, and nothing would have surfaced it. Fuzzy matching is opt-in only.
98
98
  - **Pacing is 700 ms per REQUEST, not per task** (~85/min against ClickUp's 100/min/token). A task
99
99
  costs a variable 1–3 requests, so per-task pacing cannot bound the request rate.
100
+ - **It self-chunks, because a full pass cannot finish inside the 300s watchdog.** The action works
101
+ for `CHUNK_TIME_BUDGET_SECONDS` (180s) and then re-enqueues **itself** via `_Worker::runTask`,
102
+ carrying a keyset cursor and running totals in a `resume` parameter — one `WorkerJobs` row per
103
+ chunk, each labelled `chunk N, run CONTINUES` or `run COMPLETE`. Before this, a full pass was
104
+ stamped failed by the watchdog at 326s, kept working to 818s and then **lost its entire report**
105
+ to a MySQL 2006 on its own result UPDATE. The reusable pattern (and why the chunk hand-off is
106
+ deliberately **not** retried) is in
107
+ [creating-worker-actions](./creating-worker-actions.md#self-chunking-the-pattern-for-work-that-cannot-finish-in-one-job).
108
+
109
+ ### Operational baseline (what a real pass looks like)
110
+
111
+ ~769–771 task-bearing opportunities → **1,542 field slots** (2 per opportunity). Three production
112
+ passes:
113
+
114
+ | Pass | Date | Chunks / wall time | Filled | Notes |
115
+ |---|---|---|---|---|
116
+ | 1 | 2026-08-28 | 11 chunks, 32m | 1,112 | first live pass, 0 failures |
117
+ | 2 | 2026-08-31 | 5 chunks, 15m | 39 | after 21 new ClickUp options; 1,143 already set |
118
+ | 3 | 2026-08-31 | 6 chunks, ~17m | 191 | after 100 more options; unmatched 194 → 2, coverage **89.0%** |
119
+
120
+ Every pass: **0 failures**, and totals always reconcile to `examined × 2`.
121
+
122
+ - **Chunk throughput depends on mode, sharply:** ~**170** opportunities per chunk read-only vs ~**68**
123
+ when write-heavy, because each filled field adds a paced request. Size expectations by mode, not by
124
+ the last run.
125
+ - **Dry run and live agreed EXACTLY on all five counters** (1112 / 29 / 231 / 166 / 0). That equality
126
+ is a cheap, high-value integrity check — repeat it before every live pass.
100
127
 
101
128
  ## Gotchas / known issues
102
129
 
130
+ - **⚠ THE FUZZY BLIND SPOT — fuzzy-above-floor names are invisible to the digest AND refused by the
131
+ backfill, so those gaps have no work queue and never fill.** The digest's `collectUnmatched()`
132
+ skips anything with a non-null option id
133
+ (`if ($outcome['id'] !== null || !in_array($outcome['reason'], ACTIONABLE_REASONS)) continue;`) —
134
+ and a **fuzzy hit HAS an id**, so a fuzzy name never reaches the PM email and therefore never gets
135
+ written to `Team.ClickUpLabelAliases`. Meanwhile the backfill at `exactOnly:true` **refuses** fuzzy.
136
+ Net effect measured in production: the digest reported **3 outstanding issues while 169 field slots
137
+ were actually empty** — 167 of them fuzzy skips. Coverage plateaus around 89% and nothing surfaces
138
+ the gap. Consequence for whoever is clearing the queue: **assigning every alias in the digest does
139
+ not clear the fuzzy set**, because those names were never queued — verified that
140
+ `Lee County Sheriff's Office`, `Polk County Sheriff's Office` and `Plant City Police` are all
141
+ absent from `ClickUpLabelAliases`. **Suggested fix (not implemented):** the digest should report
142
+ fuzzy-above-floor as its own actionable reason.
143
+ - **Adding ClickUp options creates NEW fuzzy near-misses — the fuzzy count can RISE as gaps are
144
+ filled.** Adding ~121 options moved names out of *unmatched*, but some landed in *fuzzy, skipped*
145
+ rather than *filled*: a customer that previously had **no** candidate now has one scoring 85–95%.
146
+ Observed unmatched **231 → 194 → 2** while fuzzy went **166 → 166 → 167**. So "add more options" is
147
+ not a complete remedy; past a point the remaining work is **aliases, not options**.
148
+ - **An alias is Pass 0 — ahead of exact AND fuzzy — so a WRONG alias lands on tasks, and
149
+ seed-if-empty makes it STICKY.** `resolveLabelOption()` (`Opportunity.php` ~L1298) returns
150
+ confidence `alias` and deliberately bypasses `exactOnly`, which is correct (a human judgement
151
+ outranks machine guessing) but means a mis-entered alias **will** be written. And because the
152
+ backfill only fills **empty** fields, no later run corrects it — someone must clear the field in
153
+ ClickUp by hand. **Operational check worth repeating before every live pass:** audit
154
+ `ClickUpLabelAliases` for mappings whose **first word differs** from the source name. Doing that on
155
+ 338 hand-entered aliases surfaced `Hewlett Packard Enterprise → HP` sitting alongside
156
+ `Hewlett-Packard Enterprise-Health and Life Sciences Team → HPE` — HP Inc. and HPE have been
157
+ separate companies since 2015.
158
+ - **The run report lists only the first 50 fuzzy entries.** With 167 fuzzy skips, the other 117 are
159
+ unreviewable *anywhere* — a real reporting gap, and the reason the production fuzzy set had to be
160
+ counted by query rather than read off the report.
103
161
  - **`splitCustomerRefName()` must handle composite sub-customer numbers.** The original pattern
104
162
  `/^(\d+)\s+(.*)$/` (digits then whitespace) could not parse `"6096:6308 Compass Group"` — the colon
105
163
  broke the match, the number stayed in the name, and normalization produced
@@ -124,6 +182,18 @@ the only way to populate them.
124
182
  injection before they go into the digest.
125
183
 
126
184
  ## Change history
185
+ - 2026-08-31 — **Made the backfill self-chunking and ran three live passes (PR #157, merged).** The
186
+ action now works 180s and re-enqueues itself with a keyset `resume` cursor, one `WorkerJobs` row per
187
+ chunk labelled CONTINUES/COMPLETE — previously a full pass was declared failed by the 300s watchdog
188
+ at 326s, kept working to 818s and lost its whole report to MySQL 2006 (pattern now documented in
189
+ [creating-worker-actions](./creating-worker-actions.md)). Recorded the operational baseline (1,542
190
+ slots; passes filled 1,112 / 39 / 191 to 89.0% coverage, 0 failures, dry-run and live agreeing
191
+ exactly on all five counters) and four gaps found while using it: the **fuzzy blind spot** (fuzzy
192
+ hits have an option id so the digest skips them *and* `exactOnly` refuses them — 3 reported issues
193
+ vs 169 genuinely empty slots, 167 fuzzy), that **adding options can raise the fuzzy count**
194
+ (unmatched 231→2 while fuzzy 166→167), that an **alias is Pass 0 and seed-if-empty makes a wrong
195
+ alias sticky** (audit first-word mismatches — caught `Hewlett Packard Enterprise → HP` vs HPE), and
196
+ that the run report **truncates the fuzzy list at 50 entries**. (ajean)
127
197
  - 2026-08-26 — **Built the client vs end-customer split (TRUE-81010, PR #142, merged).** Stakeholders
128
198
  now receives the consolidated parent (the client, NS `custentity10`) and a new **End Customer**
129
199
  field receives the opportunity `entity`; resolution is local via
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-27
9
+ updated: 2026-08-31
10
10
  owners: ["dfranks", "kyalamarthi", "ajean"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
@@ -31,7 +31,10 @@ files:
31
31
  related:
32
32
  - ./netsuite-opportunity-client-labels.md
33
33
  - ./netsuite-salesorder-open-orders-sync.md
34
+ - ./clickup-project-routing.md
34
35
  - ../architecture.md
36
+ - ../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md
37
+ - ../../../../standalone/apps/claude/workflows/mcp-tool-usage.md
35
38
  ---
36
39
 
37
40
  ## Summary
@@ -119,6 +122,23 @@ the "CU→NS direction" section.
119
122
  (text = string compare; Presales Lead users field = add-only, matches when already assigned) and
120
123
  `normalizeDescription()`.
121
124
  3. **No match → `createTask()`**: POST to list `901111987449`, `custom_item_id` = `1009`.
125
+ **That is only the task's list at BIRTH** — something outside the worker moves new opportunity
126
+ tasks to a different **home** list, which makes a healthy sync look dead. See
127
+ *⚠ A healthy sync reads as dead* under Triage below before concluding tasks aren't being created.
128
+
129
+ **The Presales-Lead gate DELAYS a task; it does not permanently lose one.** The natural (and wrong)
130
+ reading is *"no lead at creation time ⇒ that opportunity never gets a task."* Because the gate runs
131
+ on **both** `post()` and `put()`, and the claim stays open while
132
+ `Forecast.Opportunities.clickupTaskId` is still NULL, whichever later edit webhook first sees a
133
+ non-empty Presales Lead wins the claim and creates the task **then** — however old the opportunity
134
+ is. Verified live 2026-08-31: NS internalId **6682331** (opportunity **71113**, "6519 Hydro Resources
135
+ — AI Training", `Forecast.Opportunities.id` 70133 — an *old* opportunity) received task
136
+ `868kyqxq2` from a `Netsuite/Opportunity/put`, verdict
137
+ `task created (stakeholder "Hydro Resources"; end customer "Hydro Resources")`. That is a **first**
138
+ claim on a never-claimed opportunity, so it does **not** contradict the rule that once
139
+ `clickupTaskId` holds a real id every later webhook returns `skipped (already claimed)` (see
140
+ *Consequence: `updateTask()` is unreachable* below). So when triaging a backlog of
141
+ `skipped (no presales lead)`, read it as **"queued, awaiting a lead"**, not "lost".
122
142
 
123
143
  Shared helpers: `buildCustomFields()` (the field array, used by both create and update),
124
144
  `resolvePresalesUserId()` (cached `Client_True.Users.c_clickupUserIdentifier` lookup → else a
@@ -226,6 +246,84 @@ reaches **none** of the 761 existing tasks — only a separate backfill action r
226
246
  says nothing about the claim interaction, so reading the constant alone gives you the wrong model of
227
247
  what enabling it would do.
228
248
 
249
+ ## Triage: is the opportunity sync working? (`WorkerJobs.output` verdicts)
250
+
251
+ **Start here** whenever anyone reports *"no new opportunity tasks in ClickUp."* One query answers it
252
+ in seconds — and, importantly, **with no NetSuite or ClickUp access at all.** That matters: the
253
+ NetSuite MCP connector needs interactive OAuth and is routinely unavailable in a session (it was on
254
+ 2026-08-31), which blocks the two NetSuite-side checks this doc otherwise sends you to — the
255
+ `scriptdeployment` status/audience query and the API Message Queue
256
+ `custrecordcustrecord_amq_errormsg` inspection. The verdict census below is a genuine **end-to-end
257
+ substitute** for "is it working".
258
+
259
+ Every `Netsuite/Opportunity/post|put` job writes a JSON result into `Core.WorkerJobs.output`:
260
+
261
+ ```json
262
+ {"db":"created|updated opportunity <internalId> (id=<forecastId>, items=<n>)","clickup":"<verdict>"}
263
+ ```
264
+
265
+ **In practice there are only three `clickup` verdicts.** Census over 2026-08-20 → 2026-08-31
266
+ (1,218 jobs, prod):
267
+
268
+ | Verdict | Count | Share |
269
+ |---|---|---|
270
+ | `skipped (no presales lead)` | 1,047 | 86% |
271
+ | `skipped (already claimed)` | 150 | 12% |
272
+ | `task created` (optionally + the stakeholder/end-customer label suffix) | 21 | 1.7% |
273
+
274
+ - **The healthy baseline is ~2% of opportunity events producing a ClickUp task.** A **high
275
+ `no presales lead` share is NOT a fault** — it is the normal shape of the traffic, and those are
276
+ queued rather than lost (see the gate-delay note under *ClickUp opportunity task*). **Zero tasks
277
+ created on a business day** is the signal worth chasing.
278
+ - Zero error verdicts and zero `(label lookup failed)` in that window — independent confirmation
279
+ that the 2026-08-26 `logged()` NOT-NULL-column fix (PRs #150 + #151) is holding in production.
280
+ - **Query gotcha:** the `toga-db` MCP validator rejects the literal keyword `UPDATE` **even inside a
281
+ string literal or a column alias**, so match the `skipped update (update disabled)` verdict with
282
+ `LIKE '%disabled%'` and alias the column `skippedDisabled`, never `updateDisabled`. See
283
+ [MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md).
284
+
285
+ ### ⚠ A healthy sync reads as dead: new tasks do NOT stay in "Presales Qualification"
286
+
287
+ **This is the first thing to check when someone says "no opportunity tasks have been created."**
288
+ `maybeCreateClickupTask()` only ever POSTs to list `901111987449` ("Presales Qualification"), and
289
+ ClickUp's v2 API **cannot** alter a task's home list (`400 TASK_035`) — **so the worker cannot be
290
+ the mover.** Yet observed live 2026-08-31, worker-created tasks report their **home** list as
291
+ `901111987451` ("Quote/Proposal/SOW/MSA"), in folder `90117060687` ("Opportunities"), space
292
+ `90113928591` ("Opportunity & RFP Hub") — same folder and space, different list.
293
+
294
+ | Task | Opportunity (NS internalId) | Created (CDT) | Current home list |
295
+ |---|---|---|---|
296
+ | `868kyq7e1` | 75850 (7435817) | 2026-08-31 08:26 | Quote/Proposal/SOW/MSA |
297
+ | `868kyqxq2` | 71113 (6682331) | 2026-08-31 09:09 | Quote/Proposal/SOW/MSA |
298
+ | `868kxtf1n` | 75809 (7429824) | 2026-08-27 18:02 | Quote/Proposal/SOW/MSA |
299
+ | `868kxgexk` | 75791 (7427497) | 2026-08-27 10:25 | **still Presales Qualification** |
300
+
301
+ **Consequence: a developer or PM watching the "Presales Qualification" list will believe the sync is
302
+ dead while it is working.** Verify by **task id**, or across the whole **Opportunities folder** —
303
+ never by that one list.
304
+
305
+ **Open question — the mover is NOT identified.** It is not cleanly stage-driven: `868kxtf1n` and
306
+ `868kxgexk` were created the same day, both at Stage 25%, and only one moved. So it is either a
307
+ ClickUp native **Automation** with a condition not visible through the API, or a **human** moving
308
+ them. Do not assert a cause. Either way it is consistent with the documented hybrid split —
309
+ home-list movement is owned by ClickUp native Automations, secondary multi-list membership by the
310
+ worker: see
311
+ [ClickUp Project & Opportunity Multi-List Routing](./clickup-project-routing.md).
312
+
313
+ ### Watch-item: 2026-08-28 — 74 opportunity events, 0 tasks created (unconfirmed)
314
+
315
+ Tasks created per day in that window: Aug 24 = 1, Aug 25 = 7, Aug 26 = 2, Aug 27 = 4,
316
+ **Aug 28 = 0**, Aug 29–30 (weekend) = 0, Aug 31 = 2. Aug 28 was a **business day with 74 opportunity
317
+ jobs, 72 of them `skipped (no presales lead)`, and zero tasks created** — the only anomalous weekday.
318
+
319
+ **Cause NOT confirmed.** On a single data point it is just as likely that nobody assigned presales
320
+ leads that Friday. *If it recurs*, the first candidate to check is NetSuite scheduled script **2412**
321
+ ("Update Opp Status / % to Close / Presales Lead"), which auto-assigns the Presales Lead each morning
322
+ and already has its own regression history — see
323
+ [1.0 webhook: NetSuite → ClickUp opportunity sync](../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md).
324
+ Diagnostic: re-run the per-day verdict census, and if the leads really are unset in NetSuite, check
325
+ 2412's deployment status/audience before reading any PHP.
326
+
229
327
  ## Data model
230
328
 
231
329
  Writes `Forecast.Opportunities` (header) + `Forecast.OpportunityItems` (children), faithful to
@@ -554,7 +652,20 @@ deprecated** for production opportunity code.
554
652
  blocked on the Aaron stakeholder decision noted above.
555
653
 
556
654
  ## Change history
557
-
655
+ - 2026-08-31 — **Triage knowledge from a read-only "is the opportunity sync dead?" investigation — no
656
+ code changed.** Added a *Triage* section: the `Core.WorkerJobs.output` `clickup` verdict census is
657
+ the fast, NetSuite-free answer, and it has exactly **three** verdicts in practice, with a healthy
658
+ baseline of only **~2% of opportunity events producing a task** (86% are `skipped (no presales
659
+ lead)`, which is normal traffic shape, not a fault). Recorded the visibility trap that made a
660
+ working sync look dead: the worker only ever POSTs to list `901111987449` "Presales Qualification"
661
+ and **cannot** move a home list (TASK_035), yet new tasks are found homed in `901111987451`
662
+ "Quote/Proposal/SOW/MSA" — mover **not identified** (not cleanly stage-driven; ClickUp Automation
663
+ or a human), so verify by task id/folder, never by that one list. Corrected the reading of the
664
+ Presales-Lead gate: it **delays** a task, it does not lose one — a later edit webhook wins the
665
+ still-open claim whenever the lead is finally set (proven live on opportunity 71113 / internalId
666
+ 6682331). Logged 2026-08-28 (74 events, 0 tasks) as an **unconfirmed** watch-item with NS script
667
+ 2412 as the candidate to check on recurrence. Also confirmed the 2026-08-26 `logged()` fix is
668
+ holding (zero error / `(label lookup failed)` verdicts). (ajean)
558
669
  - 2026-08-27 — **Corrected the "NetSuite calls aren't logged to `Logs.Api`" gotcha — they are.** No
559
670
  `setLogging` call exists in `_underscore/Component/Api/Netsuite/Netsuite.php`, so `_ApiRequest`'s
560
671
  `$isLoggingEnabled = true` default holds and every NetSuite call (read-only SuiteQL included) writes a
@@ -704,3 +815,9 @@ deprecated** for production opportunity code.
704
815
  ## Related docs
705
816
  - `2.0/apps/worker2/architecture.md` — the WorkerJobs/SQS/Lambda model and always-HTTP-200 worker.
706
817
  - `2.0/apps/_underscore/architecture.md` — the `_Database::register()` lazy-transaction gotcha.
818
+ - [ClickUp Project & Opportunity Multi-List Routing](./clickup-project-routing.md) — why home-list
819
+ movement is owned by ClickUp native Automations and not by any worker.
820
+ - [1.0 webhook: NetSuite → ClickUp opportunity sync](../../../../1.0/apps/webhook/features/netsuite-clickup-opportunity-sync.md)
821
+ — NetSuite scheduled script 2412, which auto-assigns the Presales Lead that gates task creation.
822
+ - [MCP Tool Usage](../../../../standalone/apps/claude/workflows/mcp-tool-usage.md) — the `toga-db`
823
+ query traps hit while running the verdict census.
@@ -6,7 +6,7 @@ project: Claude Harness
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-31
10
10
  owners: ["jcardinal", "tcox", "ajean"]
11
11
  files:
12
12
  - claude/.claude/skills/kickoff/SKILL.md
@@ -141,8 +141,10 @@ trust `Core.DatabaseHosts`, not old migrations.)
141
141
  **⚠ The write-blocker is a naive string scan — it rejects `UPDATE`/`REPLACE` even inside a string
142
142
  literal.** `toga_query` refuses the whole statement if those words appear anywhere in it, so a
143
143
  perfectly read-only query like
144
- `… WHERE output LIKE '%skipped update%'` fails validation. Work around it by matching on a fragment
145
- that avoids the keyword (`LIKE '%skipped%'`), or by pulling the rows and filtering client-side —
144
+ `… WHERE output LIKE '%skipped update%'` fails validation. **A column alias trips it too** —
145
+ `… AS updateDisabled` is rejected on the same scan, so the alias has to be renamed
146
+ (`skippedDisabled`), not just the predicate. Work around it by matching on a fragment that avoids the
147
+ keyword (`LIKE '%disabled%'`, `LIKE '%skipped%'`), or by pulling the rows and filtering client-side —
146
148
  don't conclude the data isn't there.
147
149
 
148
150
  **Production sessions render in US/Central (UTC−5); most other timestamps you're comparing are
@@ -195,6 +197,9 @@ Schema names are **case-sensitive** — copy the exact value from `toga_list_sch
195
197
  and why. Database → what is actually true right now.
196
198
 
197
199
  ## Change history
200
+ - 2026-08-31 — Extended the `toga_query` write-blocker trap: the naive keyword scan also rejects a
201
+ **column alias** containing `UPDATE` (`… AS updateDisabled`), not just a string literal — rename the
202
+ alias as well as the predicate. Hit while censusing `WorkerJobs.output` sync verdicts. (ajean)
198
203
  - 2026-08-26 — Added two `toga-db` query traps: the write-blocker is a **naive string scan** that
199
204
  rejects any statement containing `UPDATE`/`REPLACE` **even inside a string literal** (so
200
205
  `LIKE '%skipped update%'` fails validation on a read-only SELECT), and **production DB sessions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.708",
3
+ "version": "1.0.709",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",